diff --git a/CHANGELOG.md b/CHANGELOG.md index e1da867..91eea53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Removed +- `decision` and `actionhook`. They existed for the hosted control plane, + which was retired on 2026-10-01. Their last importers (`dataexchange`, + `eventstream`, `handshake`) dropped them first. + ## [v0.1.0] Initial release. diff --git a/actionhook/hook.go b/actionhook/hook.go deleted file mode 100644 index fa8b097..0000000 --- a/actionhook/hook.go +++ /dev/null @@ -1,317 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package actionhook defines Pilot's versioned before/after side-effect -// boundary. It is deliberately optional: a nil Hook means the application -// executes exactly as it did before governance was attached. -package actionhook - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "fmt" - "sort" - "strings" - "time" - "unicode/utf8" - - "github.com/pilot-protocol/common/decision" -) - -const SchemaVersion uint16 = 1 - -const ( - MaxAttributes = 32 - MaxAttributeValue = 256 -) - -// Envelope is evaluated before an adapter performs a side effect. Ordinary -// local/observe hooks use only PayloadHash. A managed hosted-federation hook -// may additionally carry FederatedContent to Pilot's account ingress. -// Attributes remain typed call metadata rather than an alternative channel -// for content, secrets, local paths, or peer network addresses. -type Envelope struct { - Version uint16 `json:"version"` - ID string `json:"id"` - Action string `json:"action"` - Resource string `json:"resource"` - PayloadHash string `json:"payload_hash"` - AdapterID string `json:"adapter_id"` - CreatedAt int64 `json:"created_at"` - Attributes map[string]string `json:"attributes,omitempty"` - // FederatedContent is the complete request body submitted to Pilot's - // hosted account ingress in managed mode. It is process-local hook input: - // envelope JSON, traces, continuations, and receipts never serialize it. - FederatedContent *decision.FederatedContent `json:"-"` - // ResumeToken is adapter-local state used to find a durable continuation. - // It is deliberately excluded from JSON and never sent to an evaluator. - ResumeToken string `json:"-"` -} - -// DecisionReference is safe to persist in an action trace. It contains no -// provider request body and no opaque adapter resume state. -type DecisionReference struct { - IntentID string `json:"intent_id,omitempty"` - DecisionID string `json:"decision_id,omitempty"` - ExchangeID string `json:"exchange_id,omitempty"` - PolicyRevision uint64 `json:"policy_revision,omitempty"` - ProviderID string `json:"provider_id,omitempty"` - ApprovalTransaction string `json:"approval_transaction_id,omitempty"` - ApprovalExpiresAt int64 `json:"approval_expires_at,omitempty"` -} - -// Preflight is the hook's answer. State is process-local opaque state used by -// the same Hook during AfterAction; it is never serialized or accepted from a -// caller. ObserveOnly makes the result evidentiary and never blocking. -type Preflight struct { - Outcome decision.Outcome `json:"outcome"` - Reasons []string `json:"reasons,omitempty"` - Constraints []decision.Constraint `json:"constraints,omitempty"` - Reference DecisionReference `json:"reference,omitempty"` - ObserveOnly bool `json:"observe_only,omitempty"` - State any `json:"-"` -} - -type ObservedStatus string - -const ( - StatusSucceeded ObservedStatus = "succeeded" - StatusFailed ObservedStatus = "failed" - StatusSkipped ObservedStatus = "skipped" - StatusDenied ObservedStatus = "denied" - StatusApprovalPending ObservedStatus = "approval_pending" -) - -// ObservedResult records what actually happened after preflight. ErrorCode is -// a bounded category, never an arbitrary error string that could leak data. -type ObservedResult struct { - Status ObservedStatus `json:"status"` - ObservedAt int64 `json:"observed_at"` - ErrorCode string `json:"error_code,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` - // FederatedContent is an optional complete response or returned artifact - // for the Pilot-hosted post-hook. Like request content, it is process-local - // and is never serialized into traces or local receipts. - FederatedContent *decision.FederatedContent `json:"-"` -} - -// Hook is the universal optional side-effect boundary. AfterAction is -// evidence-only: its failure must be surfaced to telemetry, but it cannot -// retroactively change or repeat the adapter side effect. -type Hook interface { - BeforeAction(context.Context, Envelope) (Preflight, error) - AfterAction(context.Context, Envelope, Preflight, ObservedResult) error -} - -// BlockedError is returned by RequireUnconstrained when a valid preflight does -// not authorize the adapter to execute immediately. -type BlockedError struct { - Outcome decision.Outcome - Reference DecisionReference - Reasons []string -} - -func (err *BlockedError) Error() string { - switch err.Outcome { - case decision.Deny: - return "actionhook: action denied" - case decision.ApprovalRequired: - if err.Reference.ApprovalTransaction != "" { - return fmt.Sprintf("actionhook: action requires approval transaction %s", err.Reference.ApprovalTransaction) - } - return "actionhook: action requires approval" - case decision.Constrain: - return "actionhook: adapter cannot enforce returned constraints" - default: - return fmt.Sprintf("actionhook: action is not executable (%s)", err.Outcome) - } -} - -// RequireUnconstrained is the safe execution check for adapters that do not -// implement constraint operators. Observe-only hooks never block execution. -func (preflight Preflight) RequireUnconstrained() error { - if preflight.ObserveOnly { - return nil - } - switch preflight.Outcome { - case decision.Allow: - if len(preflight.Constraints) != 0 { - return &BlockedError{Outcome: decision.Constrain, Reference: preflight.Reference, Reasons: append([]string(nil), preflight.Reasons...)} - } - return nil - case decision.Constrain, decision.Deny, decision.ApprovalRequired: - return &BlockedError{Outcome: preflight.Outcome, Reference: preflight.Reference, Reasons: append([]string(nil), preflight.Reasons...)} - default: - return fmt.Errorf("actionhook: invalid preflight outcome %q", preflight.Outcome) - } -} - -func NewEnvelope(action, resource, payloadHash, adapterID string, attributes map[string]string, now time.Time) (Envelope, error) { - var nonce [16]byte - if _, err := rand.Read(nonce[:]); err != nil { - return Envelope{}, fmt.Errorf("actionhook: generate action id: %w", err) - } - envelope := Envelope{ - Version: SchemaVersion, ID: "action-" + hex.EncodeToString(nonce[:]), - Action: action, Resource: resource, PayloadHash: payloadHash, - AdapterID: adapterID, CreatedAt: now.UTC().Unix(), Attributes: cloneAttributes(attributes), - } - if err := envelope.Validate(); err != nil { - return Envelope{}, err - } - return envelope, nil -} - -func NewFederatedEnvelope(action, resource, adapterID string, content decision.FederatedContent, attributes map[string]string, now time.Time) (Envelope, error) { - if err := content.Validate(); err != nil { - return Envelope{}, err - } - payloadHash, err := content.Disclosure.Hash() - if err != nil { - return Envelope{}, err - } - envelope, err := NewEnvelope(action, resource, payloadHash, adapterID, attributes, now) - if err != nil { - return Envelope{}, err - } - cloned := content.Clone() - envelope.FederatedContent = &cloned - if err := envelope.Validate(); err != nil { - return Envelope{}, err - } - return envelope, nil -} - -func (envelope Envelope) Validate() error { - if envelope.Version != SchemaVersion { - return fmt.Errorf("actionhook: unsupported envelope version %d", envelope.Version) - } - if !validIdentifier(envelope.ID, 128) || !validDottedName(envelope.Action) || !validIdentifier(envelope.AdapterID, 128) { - return fmt.Errorf("actionhook: invalid action, adapter, or envelope identity") - } - if !validText(envelope.Resource, 1024) { - return fmt.Errorf("actionhook: invalid resource") - } - if len(envelope.PayloadHash) != sha256.Size*2 { - return fmt.Errorf("actionhook: payload hash must be SHA-256") - } - if _, err := hex.DecodeString(envelope.PayloadHash); err != nil || envelope.PayloadHash != strings.ToLower(envelope.PayloadHash) { - return fmt.Errorf("actionhook: payload hash must be lowercase hexadecimal") - } - if envelope.CreatedAt <= 0 { - return fmt.Errorf("actionhook: invalid creation time") - } - if envelope.FederatedContent != nil { - if err := envelope.FederatedContent.Validate(); err != nil { - return err - } - bindingHash, err := envelope.FederatedContent.Disclosure.Hash() - if err != nil || bindingHash != envelope.PayloadHash { - return fmt.Errorf("actionhook: federated content does not match payload hash") - } - } - if !validTextAllowEmpty(envelope.ResumeToken, 1024) { - return fmt.Errorf("actionhook: invalid local resume token") - } - return validateAttributes(envelope.Attributes) -} - -func (result ObservedResult) Validate() error { - switch result.Status { - case StatusSucceeded, StatusFailed, StatusSkipped, StatusDenied, StatusApprovalPending: - default: - return fmt.Errorf("actionhook: invalid observed status %q", result.Status) - } - if result.ObservedAt <= 0 || (result.ErrorCode != "" && !validIdentifier(result.ErrorCode, 128)) { - return fmt.Errorf("actionhook: invalid observed result") - } - if result.FederatedContent != nil { - if err := result.FederatedContent.Validate(); err != nil { - return err - } - } - return validateAttributes(result.Attributes) -} - -// HashMetadata creates the payload binding for actions whose policy context is -// entirely metadata. Length-prefixing and sorting prevent ambiguous joins. -func HashMetadata(values map[string]string) string { - hash := sha256.New() - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - writeHashPart(hash, key) - writeHashPart(hash, values[key]) - } - return hex.EncodeToString(hash.Sum(nil)) -} - -func writeHashPart(hash interface{ Write([]byte) (int, error) }, value string) { - length := uint64(len(value)) - var encoded [8]byte - for index := 7; index >= 0; index-- { - encoded[index] = byte(length) - length >>= 8 - } - _, _ = hash.Write(encoded[:]) - _, _ = hash.Write([]byte(value)) -} - -func validateAttributes(attributes map[string]string) error { - if len(attributes) > MaxAttributes { - return fmt.Errorf("actionhook: at most %d attributes are allowed", MaxAttributes) - } - for key, value := range attributes { - if !validIdentifier(key, 64) || !validTextAllowEmpty(value, MaxAttributeValue) { - return fmt.Errorf("actionhook: invalid attribute %q", key) - } - } - return nil -} - -func cloneAttributes(attributes map[string]string) map[string]string { - if len(attributes) == 0 { - return nil - } - clone := make(map[string]string, len(attributes)) - for key, value := range attributes { - clone[key] = value - } - return clone -} - -func validIdentifier(value string, max int) bool { - if value == "" || len(value) > max { - return false - } - for _, character := range value { - if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || character == '-' || character == '_' || character == '.' || character == ':' { - continue - } - return false - } - return true -} - -func validDottedName(value string) bool { - return strings.Contains(value, ".") && validIdentifier(value, 128) && value == strings.ToLower(value) -} - -func validText(value string, max int) bool { return value != "" && validTextAllowEmpty(value, max) } - -func validTextAllowEmpty(value string, max int) bool { - if len(value) > max || !utf8.ValidString(value) { - return false - } - for _, character := range value { - if character < 0x20 || character == 0x7f { - return false - } - } - return true -} diff --git a/actionhook/hook_test.go b/actionhook/hook_test.go deleted file mode 100644 index 4036cb4..0000000 --- a/actionhook/hook_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package actionhook - -import ( - "errors" - "testing" - "time" - - "github.com/pilot-protocol/common/decision" -) - -func TestEnvelopeAndMetadataHashAreStableAndPrivacyBounded(t *testing.T) { - first := HashMetadata(map[string]string{"peer_id": "42", "reason": "mutual"}) - second := HashMetadata(map[string]string{"reason": "mutual", "peer_id": "42"}) - if first != second { - t.Fatal("metadata hash changed with map iteration order") - } - envelope, err := NewEnvelope("trust.auto_accept", "agent:42", first, "pilot.handshake", map[string]string{"reason": "mutual"}, time.Unix(1785700000, 0)) - if err != nil { - t.Fatal(err) - } - if envelope.ID == "" || envelope.Attributes["reason"] != "mutual" { - t.Fatalf("unexpected envelope: %+v", envelope) - } - if _, err := NewEnvelope("trust.auto_accept", "agent:42", first, "pilot.handshake", map[string]string{"prompt": "line\nsecret"}, time.Now()); err == nil { - t.Fatal("control characters must not enter metadata") - } -} - -func TestPreflightBlocksEverythingExceptExplicitAllow(t *testing.T) { - for _, outcome := range []decision.Outcome{decision.Deny, decision.ApprovalRequired, decision.Constrain} { - err := (Preflight{Outcome: outcome}).RequireUnconstrained() - var blocked *BlockedError - if !errors.As(err, &blocked) || blocked.Outcome != outcome { - t.Fatalf("outcome %q was not safely blocked: %v", outcome, err) - } - } - if err := (Preflight{Outcome: decision.Allow}).RequireUnconstrained(); err != nil { - t.Fatalf("allow was blocked: %v", err) - } - if err := (Preflight{Outcome: decision.Deny, ObserveOnly: true}).RequireUnconstrained(); err != nil { - t.Fatalf("observe-only result blocked execution: %v", err) - } -} diff --git a/decision/approval.go b/decision/approval.go deleted file mode 100644 index 95ef11f..0000000 --- a/decision/approval.go +++ /dev/null @@ -1,400 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "sort" - "strings" - "time" -) - -const ( - ApprovalRequestDomain = "pilot-approval-request-v1" - ApprovalGrantDomain = "pilot-approval-grant-v1" -) - -// ApprovalRequest is a deterministic view of one signed -// approval_required decision. It adds no authority by itself. -type ApprovalRequest struct { - Version uint16 `json:"version"` - ID string `json:"id"` - IntentHash string `json:"intent_hash"` - DecisionID string `json:"decision_id"` - DecisionHash string `json:"decision_hash"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - PolicyRevision uint64 `json:"policy_revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - RequestedAt int64 `json:"requested_at"` - ExpiresAt int64 `json:"expires_at"` -} - -// ApprovalGrant is a purpose-limited approver signature over one exact -// ApprovalRequest. It can allow or add constraints; it cannot be reused for a -// different intent, decision, tenant, or agent. -type ApprovalGrant struct { - Version uint16 `json:"version"` - ID string `json:"id"` - RequestHash string `json:"request_hash"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - ApproverID string `json:"approver_id"` - Outcome Outcome `json:"outcome"` - Constraints []Constraint `json:"constraints,omitempty"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - Nonce string `json:"nonce"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func NewApprovalRequest(intent Intent, initial Decision) (ApprovalRequest, error) { - intentHash, err := intent.Hash() - if err != nil { - return ApprovalRequest{}, err - } - decisionHash, err := initial.Hash() - if err != nil { - return ApprovalRequest{}, err - } - request := ApprovalRequest{ - Version: SchemaVersion, ID: approvalRequestID(intentHash, initial.ID), - IntentHash: intentHash, DecisionID: initial.ID, DecisionHash: decisionHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, - PolicyRevision: initial.PolicyRevision, RevocationEpoch: initial.RevocationEpoch, - RequestedAt: initial.IssuedAt, ExpiresAt: initial.ExpiresAt, - } - if err := request.ValidateFor(intent, initial); err != nil { - return ApprovalRequest{}, err - } - return request, nil -} - -func (request ApprovalRequest) Validate() error { - if request.Version != SchemaVersion || !lowerHex(request.ID, 64) || - !lowerHex(request.IntentHash, 64) || !lowerHex(request.DecisionHash, 64) { - return fmt.Errorf("decision: invalid approval request identity") - } - for name, value := range map[string]string{ - "decision_id": request.DecisionID, "tenant_id": request.TenantID, "agent_id": request.AgentID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if request.RequestedAt <= 0 || request.ExpiresAt <= request.RequestedAt || - request.ExpiresAt-request.RequestedAt > int64(MaxDecisionTTL/time.Second) { - return fmt.Errorf("decision: invalid approval request validity window") - } - if request.ID != approvalRequestID(request.IntentHash, request.DecisionID) { - return fmt.Errorf("decision: noncanonical approval request id") - } - return nil -} - -func (request ApprovalRequest) ValidateFor(intent Intent, initial Decision) error { - if err := request.Validate(); err != nil { - return err - } - if initial.Outcome != ApprovalRequired { - return fmt.Errorf("decision: approval request requires approval_required decision") - } - intentHash, err := intent.Hash() - if err != nil { - return err - } - decisionHash, err := initial.Hash() - if err != nil { - return err - } - if request.IntentHash != intentHash || request.DecisionHash != decisionHash || request.DecisionID != initial.ID || - request.TenantID != intent.TenantID || request.AgentID != intent.AgentID || - request.PolicyRevision != initial.PolicyRevision || request.RevocationEpoch != initial.RevocationEpoch || - request.RequestedAt != initial.IssuedAt || request.ExpiresAt != initial.ExpiresAt { - return fmt.Errorf("decision: approval request object binding mismatch") - } - return nil -} - -func (request ApprovalRequest) Canonical() ([]byte, error) { - if err := request.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(ApprovalRequestDomain) - writer.u16(request.Version) - writer.string(request.ID) - writer.string(request.IntentHash) - writer.string(request.DecisionID) - writer.string(request.DecisionHash) - writer.string(request.TenantID) - writer.string(request.AgentID) - writer.u64(request.PolicyRevision) - writer.u64(request.RevocationEpoch) - writer.i64(request.RequestedAt) - writer.i64(request.ExpiresAt) - return writer.Bytes(), nil -} - -func (request ApprovalRequest) Hash() (string, error) { return hashCanonical(request.Canonical()) } - -func NewApprovalGrant(request ApprovalRequest, approverID string, outcome Outcome, constraints []Constraint, issuedAt, expiresAt time.Time, nonce, keyID string) (ApprovalGrant, error) { - requestHash, err := request.Hash() - if err != nil { - return ApprovalGrant{}, err - } - grant := ApprovalGrant{ - Version: SchemaVersion, RequestHash: requestHash, - TenantID: request.TenantID, AgentID: request.AgentID, ApproverID: approverID, - Outcome: outcome, Constraints: append([]Constraint(nil), constraints...), - IssuedAt: issuedAt.Unix(), ExpiresAt: expiresAt.Unix(), Nonce: nonce, KeyID: keyID, - } - grant.ID = approvalGrantID(requestHash, approverID, nonce) - if err := grant.Validate(); err != nil { - return ApprovalGrant{}, err - } - return grant, nil -} - -func (grant ApprovalGrant) Validate() error { - if grant.Version != SchemaVersion || !lowerHex(grant.ID, 64) || !lowerHex(grant.RequestHash, 64) || !lowerHex(grant.Nonce, 32) { - return fmt.Errorf("decision: invalid approval grant identity") - } - for name, value := range map[string]string{ - "tenant_id": grant.TenantID, "agent_id": grant.AgentID, - "approver_id": grant.ApproverID, "key_id": grant.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if grant.Outcome != Allow && grant.Outcome != Constrain { - return fmt.Errorf("decision: approval grant outcome must allow or constrain") - } - probe := Decision{ - Version: SchemaVersion, ID: "approval-validation", IntentHash: strings.Repeat("0", 64), - TenantID: grant.TenantID, AgentID: grant.AgentID, Outcome: grant.Outcome, - Constraints: grant.Constraints, ProviderID: "approval-validation", - IssuedAt: 1, ExpiresAt: 2, KeyID: "approval-validation", - } - if err := probe.Validate(); err != nil { - return err - } - if grant.IssuedAt <= 0 || grant.ExpiresAt <= grant.IssuedAt || - grant.ExpiresAt-grant.IssuedAt > int64(MaxDecisionTTL/time.Second) { - return fmt.Errorf("decision: invalid approval grant validity window") - } - if grant.ID != approvalGrantID(grant.RequestHash, grant.ApproverID, grant.Nonce) { - return fmt.Errorf("decision: noncanonical approval grant id") - } - return nil -} - -func (grant ApprovalGrant) Canonical() ([]byte, error) { - if err := grant.Validate(); err != nil { - return nil, err - } - constraints := append([]Constraint(nil), grant.Constraints...) - sort.Slice(constraints, func(i, j int) bool { - if constraints[i].Key != constraints[j].Key { - return constraints[i].Key < constraints[j].Key - } - if constraints[i].Operator != constraints[j].Operator { - return constraints[i].Operator < constraints[j].Operator - } - return constraints[i].Value < constraints[j].Value - }) - writer := canonicalWriter{} - writer.string(ApprovalGrantDomain) - writer.u16(grant.Version) - writer.string(grant.ID) - writer.string(grant.RequestHash) - writer.string(grant.TenantID) - writer.string(grant.AgentID) - writer.string(grant.ApproverID) - writer.string(string(grant.Outcome)) - writer.u16(uint16(len(constraints))) - for _, constraint := range constraints { - writer.string(constraint.Key) - writer.string(constraint.Operator) - writer.string(constraint.Value) - } - writer.i64(grant.IssuedAt) - writer.i64(grant.ExpiresAt) - writer.string(grant.Nonce) - writer.string(grant.KeyID) - return writer.Bytes(), nil -} - -func (grant ApprovalGrant) Hash() (string, error) { return hashCanonical(grant.Canonical()) } - -func (grant *ApprovalGrant) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid approval private key") - } - canonical, err := grant.Canonical() - if err != nil { - return err - } - grant.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (grant ApprovalGrant) VerifyFor(request ApprovalRequest, publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := grant.Canonical() - if err != nil { - return err - } - if err := verifyFresh("approval grant", grant.IssuedAt, grant.ExpiresAt, now); err != nil { - return err - } - if err := verifySignature("approval grant", publicKey, canonical, grant.Signature); err != nil { - return err - } - requestHash, err := request.Hash() - if err != nil { - return err - } - if grant.RequestHash != requestHash || grant.TenantID != request.TenantID || grant.AgentID != request.AgentID || - grant.ExpiresAt > request.ExpiresAt || grant.IssuedAt < request.RequestedAt-int64(MaxClockSkew/time.Second) { - return fmt.Errorf("decision: approval grant request binding mismatch") - } - return nil -} - -func (grant ApprovalGrant) EvidenceReason() (string, error) { - hash, err := grant.Hash() - if err != nil { - return "", err - } - return "approval:" + hash, nil -} - -// IssueApprovedDecision is the authority-side transition from an -// approval_required result plus a valid human grant to a final signed result. -func IssueApprovedDecision(intent Intent, initial Decision, request ApprovalRequest, grant ApprovalGrant, decisionPublicKey, approvalPublicKey ed25519.PublicKey, decisionPrivateKey ed25519.PrivateKey, providerID, decisionKeyID string, now time.Time) (Decision, error) { - if len(decisionPrivateKey) != ed25519.PrivateKeySize { - return Decision{}, fmt.Errorf("decision: invalid decision private key") - } - return IssueApprovedDecisionWithSigner(intent, initial, request, grant, decisionPublicKey, approvalPublicKey, - func(message []byte) ([]byte, error) { return ed25519.Sign(decisionPrivateKey, message), nil }, providerID, decisionKeyID, now) -} - -// IssueApprovedDecisionWithSigner is the remote-key-compatible variant of -// IssueApprovedDecision. signer signs the final canonical Decision with the -// same public key that verified the initial authority decision. -func IssueApprovedDecisionWithSigner(intent Intent, initial Decision, request ApprovalRequest, grant ApprovalGrant, decisionPublicKey, approvalPublicKey ed25519.PublicKey, signer func([]byte) ([]byte, error), providerID, decisionKeyID string, now time.Time) (Decision, error) { - if signer == nil { - return Decision{}, fmt.Errorf("decision: decision signer is required") - } - if err := initial.VerifyFor(intent, decisionPublicKey, now); err != nil { - return Decision{}, fmt.Errorf("decision: initial approval decision: %w", err) - } - if err := request.ValidateFor(intent, initial); err != nil { - return Decision{}, err - } - if err := grant.VerifyFor(request, approvalPublicKey, now); err != nil { - return Decision{}, err - } - grantHash, err := grant.Hash() - if err != nil { - return Decision{}, err - } - expiresAt := grant.ExpiresAt - if expiresAt > intent.ExpiresAt { - expiresAt = intent.ExpiresAt - } - if expiresAt <= now.Unix() { - return Decision{}, fmt.Errorf("decision: approval expires before final decision") - } - intentHash, _ := intent.Hash() - final := Decision{ - Version: SchemaVersion, ID: domainHash("pilot-approved-decision-v1/id", initial.ID, grantHash), - IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, - Outcome: grant.Outcome, Reasons: []string{"approval:" + grantHash}, - Constraints: append([]Constraint(nil), grant.Constraints...), - PolicyRevision: initial.PolicyRevision, RevocationEpoch: initial.RevocationEpoch, - ProviderID: providerID, IssuedAt: now.Unix(), ExpiresAt: expiresAt, KeyID: decisionKeyID, - } - if err := final.SignWith(signer); err != nil { - return Decision{}, err - } - if err := final.Verify(decisionPublicKey, now); err != nil { - return Decision{}, fmt.Errorf("decision: issued approved decision key mismatch: %w", err) - } - return final, nil -} - -// VerifyApprovedDecision validates the complete local evidence package for a -// final allow/constrain issued after human approval. -func VerifyApprovedDecision(intent Intent, initial, final Decision, request ApprovalRequest, grant ApprovalGrant, decisionKey, approvalKey ed25519.PublicKey, now time.Time) error { - if err := initial.VerifyFor(intent, decisionKey, now); err != nil { - return fmt.Errorf("decision: initial approval decision: %w", err) - } - if err := request.ValidateFor(intent, initial); err != nil { - return err - } - if err := grant.VerifyFor(request, approvalKey, now); err != nil { - return err - } - if err := final.VerifyFor(intent, decisionKey, now); err != nil { - return fmt.Errorf("decision: final approved decision: %w", err) - } - if final.Outcome != grant.Outcome || !constraintsEqual(final.Constraints, grant.Constraints) || - final.PolicyRevision < request.PolicyRevision || final.RevocationEpoch < request.RevocationEpoch || - final.IssuedAt < grant.IssuedAt-int64(MaxClockSkew/time.Second) || final.ExpiresAt > grant.ExpiresAt { - return fmt.Errorf("decision: final decision exceeds approval grant") - } - reason, _ := grant.EvidenceReason() - for _, candidate := range final.Reasons { - if candidate == reason { - return nil - } - } - return fmt.Errorf("decision: final decision does not bind approval evidence") -} - -func approvalRequestID(intentHash, decisionID string) string { - return domainHash(ApprovalRequestDomain+"/id", intentHash, decisionID) -} - -func approvalGrantID(requestHash, approverID, nonce string) string { - return domainHash(ApprovalGrantDomain+"/id", requestHash, approverID, nonce) -} - -func domainHash(domain string, values ...string) string { - hash := sha256.New() - writer := canonicalWriter{} - writer.string(domain) - for _, value := range values { - writer.string(value) - } - hash.Write(writer.Bytes()) - return hex.EncodeToString(hash.Sum(nil)) -} - -func constraintsEqual(first, second []Constraint) bool { - if len(first) != len(second) { - return false - } - key := func(constraint Constraint) string { - return constraint.Key + "\x00" + constraint.Operator + "\x00" + constraint.Value - } - counts := make(map[string]int, len(first)) - for _, constraint := range first { - counts[key(constraint)]++ - } - for _, constraint := range second { - identity := key(constraint) - if counts[identity] == 0 { - return false - } - counts[identity]-- - } - return true -} diff --git a/decision/approval_test.go b/decision/approval_test.go deleted file mode 100644 index d1f9ef3..0000000 --- a/decision/approval_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bytes" - "crypto/ed25519" - "crypto/rand" - "strings" - "testing" - "time" -) - -func TestApprovalConformanceVector(t *testing.T) { - decisionPrivate := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x44}, ed25519.SeedSize)) - approvalPrivate := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x55}, ed25519.SeedSize)) - intent := Intent{ - Version: SchemaVersion, ID: "approval-vector-intent", TenantID: "tenant-vector", AgentID: "agent-vector", - Action: "wallet.pay", Resource: "invoice/vector", PayloadHash: HashPayload([]byte("vector")), Risk: RiskHigh, - IssuedAt: 1785500000, ExpiresAt: 1785500120, Nonce: strings.Repeat("0", 32), KeyID: "agent-key", - } - intentHash, _ := intent.Hash() - initial := Decision{ - Version: SchemaVersion, ID: "approval-vector-decision", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: ApprovalRequired, - PolicyRevision: 4, RevocationEpoch: 2, ProviderID: "vector-provider", - IssuedAt: 1785500000, ExpiresAt: 1785500120, KeyID: "decision-key", - } - _ = initial.Sign(decisionPrivate) - request, err := NewApprovalRequest(intent, initial) - if err != nil { - t.Fatal(err) - } - grant, err := NewApprovalGrant(request, "approver-vector", Allow, nil, time.Unix(1785500030, 0), time.Unix(1785500100, 0), strings.Repeat("1", 32), "approval-key") - if err != nil { - t.Fatal(err) - } - _ = grant.Sign(approvalPrivate) - requestHash, _ := request.Hash() - grantHash, _ := grant.Hash() - const expectedRequestHash = "d346725bf8dd865e31286411094bf4cc3454de877dffae2942a72a94a180108c" - const expectedGrantHash = "92da335aa12c523693d9cc711bfc092d96c38f293364c7ebe6977485394abaee" - const expectedGrantSignature = "lFs4C50SVSUrYLb10cj3PYURRwfgxEz4JSxpjK/74tR+QvuelHOpwlfDvp7dCo09R7dZpTyX91m4el+ZhdMLDQ==" - if requestHash != expectedRequestHash || grantHash != expectedGrantHash || grant.Signature != expectedGrantSignature { - t.Fatalf("update vector: request=%s grant=%s signature=%s", requestHash, grantHash, grant.Signature) - } -} - -func approvalFixture(t *testing.T) (Intent, Decision, ApprovalRequest, ApprovalGrant, Decision, ed25519.PublicKey, ed25519.PrivateKey, ed25519.PublicKey, ed25519.PrivateKey, time.Time) { - t.Helper() - decisionPublic, decisionPrivate, _ := ed25519.GenerateKey(rand.Reader) - approvalPublic, approvalPrivate, _ := ed25519.GenerateKey(rand.Reader) - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - intentHash, _ := intent.Hash() - initial := Decision{ - Version: SchemaVersion, ID: "approval-needed", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: ApprovalRequired, - Reasons: []string{"human approval required"}, PolicyRevision: 7, RevocationEpoch: 2, - ProviderID: "managed", IssuedAt: now.Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "decision-key-1", - } - if err := initial.Sign(decisionPrivate); err != nil { - t.Fatal(err) - } - request, err := NewApprovalRequest(intent, initial) - if err != nil { - t.Fatal(err) - } - grant, err := NewApprovalGrant( - request, "approver-1", Constrain, - []Constraint{{Key: "amount", Operator: "max", Value: "100"}}, - now.Add(30*time.Second), now.Add(110*time.Second), strings.Repeat("1", 32), "approval-key-1", - ) - if err != nil { - t.Fatal(err) - } - if err := grant.Sign(approvalPrivate); err != nil { - t.Fatal(err) - } - reason, _ := grant.EvidenceReason() - final := Decision{ - Version: SchemaVersion, ID: "approval-final", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: Constrain, - Reasons: []string{reason}, Constraints: append([]Constraint(nil), grant.Constraints...), - PolicyRevision: 7, RevocationEpoch: 2, ProviderID: "managed", - IssuedAt: now.Add(30 * time.Second).Unix(), ExpiresAt: now.Add(100 * time.Second).Unix(), KeyID: "decision-key-1", - } - if err := final.Sign(decisionPrivate); err != nil { - t.Fatal(err) - } - return intent, initial, request, grant, final, decisionPublic, decisionPrivate, approvalPublic, approvalPrivate, now -} - -func TestApprovedDecisionEvidenceIsFullyBound(t *testing.T) { - t.Parallel() - intent, initial, request, grant, final, decisionKey, _, approvalKey, _, now := approvalFixture(t) - if err := VerifyApprovedDecision(intent, initial, final, request, grant, decisionKey, approvalKey, now.Add(45*time.Second)); err != nil { - t.Fatalf("valid approved decision rejected: %v", err) - } - tampered := final - tampered.Constraints = []Constraint{{Key: "amount", Operator: "max", Value: "1000"}} - if err := VerifyApprovedDecision(intent, initial, tampered, request, grant, decisionKey, approvalKey, now.Add(45*time.Second)); err == nil { - t.Fatal("expanded final constraint was accepted") - } - tampered = final - tampered.Reasons = []string{"approval:" + strings.Repeat("0", 64)} - if err := VerifyApprovedDecision(intent, initial, tampered, request, grant, decisionKey, approvalKey, now.Add(45*time.Second)); err == nil { - t.Fatal("final decision without bound approval hash was accepted") - } -} - -func TestApprovalGrantRejectsTamperCrossRequestAndExpiry(t *testing.T) { - t.Parallel() - intent, initial, request, grant, _, _, _, approvalKey, _, now := approvalFixture(t) - wrongRequest := request - wrongRequest.IntentHash = strings.Repeat("a", 64) - wrongRequest.ID = approvalRequestID(wrongRequest.IntentHash, wrongRequest.DecisionID) - if err := grant.VerifyFor(wrongRequest, approvalKey, now.Add(45*time.Second)); err == nil { - t.Fatal("approval grant crossed requests") - } - tampered := grant - tampered.ApproverID = "approver-2" - tampered.ID = approvalGrantID(tampered.RequestHash, tampered.ApproverID, tampered.Nonce) - if err := tampered.VerifyFor(request, approvalKey, now.Add(45*time.Second)); err == nil { - t.Fatal("tampered approval grant was accepted") - } - if err := grant.VerifyFor(request, approvalKey, now.Add(5*time.Minute)); err == nil { - t.Fatal("expired approval grant was accepted") - } - otherIntent := intent - otherIntent.ID = "another-intent" - if err := request.ValidateFor(otherIntent, initial); err == nil { - t.Fatal("approval request crossed intents") - } -} - -func TestIssueApprovedDecisionProducesLocallyVerifiableResult(t *testing.T) { - t.Parallel() - intent, initial, request, grant, _, decisionPublic, decisionPrivate, approvalPublic, _, now := approvalFixture(t) - final, err := IssueApprovedDecision( - intent, initial, request, grant, decisionPublic, approvalPublic, decisionPrivate, - "managed", "decision-key-1", now.Add(45*time.Second), - ) - if err != nil { - t.Fatal(err) - } - if err := VerifyApprovedDecision(intent, initial, final, request, grant, decisionPublic, approvalPublic, now.Add(45*time.Second)); err != nil { - t.Fatalf("issued approved decision did not verify: %v", err) - } -} diff --git a/decision/decision.go b/decision/decision.go deleted file mode 100644 index 7036b78..0000000 --- a/decision/decision.go +++ /dev/null @@ -1,558 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package decision defines the small signed object boundary shared by local -// enforcers and managed or self-hosted authorization providers. -package decision - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "encoding/hex" - "fmt" - "io" - "sort" - "strings" - "time" - "unicode/utf8" -) - -const ( - SchemaVersion uint16 = 1 - IntentDomain = "pilot-intent-v1" - DecisionDomain = "pilot-decision-v1" - - MaxClockSkew = time.Minute - MaxIntentTTL = 5 * time.Minute - MaxDecisionTTL = 5 * time.Minute -) - -type RiskClass string - -const ( - RiskLow RiskClass = "low" - RiskMedium RiskClass = "medium" - RiskHigh RiskClass = "high" - RiskCritical RiskClass = "critical" -) - -type Outcome string - -const ( - Allow Outcome = "allow" - Deny Outcome = "deny" - Constrain Outcome = "constrain" - ApprovalRequired Outcome = "approval_required" -) - -// Constraint narrows an allowed action. Enforcers must understand every -// returned operator; an unknown constraint is a denial, never an invitation to -// ignore it. -type Constraint struct { - Key string `json:"key"` - Operator string `json:"operator"` - Value string `json:"value"` -} - -// Intent is the privacy-preserving authorization request. PayloadHash binds -// the actual action body without requiring the authority plane to retain it. -type Intent struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Action string `json:"action"` - Resource string `json:"resource"` - // MandateID is optional for compatibility with the original v1 wire - // profile. Enterprise mandate ceilings require it; when present it changes - // the canonical signing domain so an unmandated Intent cannot be replayed - // as a delegated one. - MandateID string `json:"mandate_id,omitempty"` - Audience string `json:"audience,omitempty"` - Purpose string `json:"purpose,omitempty"` - PayloadHash string `json:"payload_hash"` - Risk RiskClass `json:"risk"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - Nonce string `json:"nonce"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -// Decision is an issuer-signed answer tied to one exact Intent hash, tenant, -// and agent. PolicyRevision and RevocationEpoch make stale-state behavior -// observable at the enforcement point. -type Decision struct { - Version uint16 `json:"version"` - ID string `json:"id"` - IntentHash string `json:"intent_hash"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Outcome Outcome `json:"outcome"` - Reasons []string `json:"reasons,omitempty"` - Constraints []Constraint `json:"constraints,omitempty"` - PolicyRevision uint64 `json:"policy_revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - ProviderID string `json:"provider_id"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -// Authorizer is the complete provider hook. A deterministic local evaluator, -// managed policy service, or semantic/LLM evaluator implements the same one -// method. Provider transport and billing are deliberately outside this API. -type Authorizer interface { - Authorize(context.Context, Intent) (Decision, error) -} - -// DisclosureAuthorizer is an explicit extension point for evaluators that can -// inspect typed, hash-bound disclosure metadata. A caller must never fall back -// to Authorize when a DisclosureBinding is present: doing so would silently -// ignore labels or residency that a required profile expects to govern. -type DisclosureAuthorizer interface { - AuthorizeDisclosure(context.Context, Intent, DisclosureBinding) (Decision, error) -} - -// FederatedContentAuthorizer is implemented only by Pilot's hosted exchange -// evaluation path. It receives the exact body after the caller's signed Intent -// and hash-bound DisclosureBinding have been verified. Implementations may -// preserve or narrow authority; content can never be used to expand a signed -// deterministic result. -type FederatedContentAuthorizer interface { - AuthorizeFederatedContent(context.Context, Intent, FederatedContent) (Decision, error) -} - -// DisclosureContentInspector is a receiver-local DLP hook. It receives the -// exact bounded payload reader only after a governed transport has verified -// its Intent, Decision, and (when present) DisclosureBinding. Implementations -// must not treat a successful inspection as authority to expand a Decision; -// they can only reject the delivery. The central authority never invokes this -// interface, keeping ordinary peer payloads out of the decision plane. -type DisclosureContentInspector interface { - InspectDisclosureContent(ctx context.Context, intent Intent, disclosure *DisclosureBinding, contentType, filename string, content io.Reader) error -} - -// HashPayload returns the opaque payload binding placed in Intent.PayloadHash. -func HashPayload(payload []byte) string { - sum := sha256.Sum256(payload) - return hex.EncodeToString(sum[:]) -} - -// NewNonce returns a 128-bit random lowercase-hex nonce. -func NewNonce() (string, error) { - var nonce [16]byte - if _, err := rand.Read(nonce[:]); err != nil { - return "", fmt.Errorf("decision: generate nonce: %w", err) - } - return hex.EncodeToString(nonce[:]), nil -} - -func (i Intent) Validate() error { - if i.Version != SchemaVersion { - return fmt.Errorf("decision: intent version %d is unsupported", i.Version) - } - for name, value := range map[string]string{ - "id": i.ID, "tenant_id": i.TenantID, "agent_id": i.AgentID, "key_id": i.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if !validAction(i.Action) { - return fmt.Errorf("decision: invalid action %q", i.Action) - } - if err := validateText("resource", i.Resource, 1024, false); err != nil { - return err - } - if i.MandateID != "" { - if err := validateIdentifier("mandate_id", i.MandateID); err != nil { - return err - } - } - if err := validateText("audience", i.Audience, 256, true); err != nil { - return err - } - if err := validateText("purpose", i.Purpose, 256, true); err != nil { - return err - } - if !lowerHex(i.PayloadHash, 64) { - return fmt.Errorf("decision: payload_hash must be 64 lowercase hex characters") - } - switch i.Risk { - case RiskLow, RiskMedium, RiskHigh, RiskCritical: - default: - return fmt.Errorf("decision: invalid risk class %q", i.Risk) - } - if !lowerHex(i.Nonce, 32) { - return fmt.Errorf("decision: nonce must be 32 lowercase hex characters") - } - if err := validateWindow("intent", i.IssuedAt, i.ExpiresAt, MaxIntentTTL); err != nil { - return err - } - return nil -} - -func (d Decision) Validate() error { - if d.Version != SchemaVersion { - return fmt.Errorf("decision: decision version %d is unsupported", d.Version) - } - for name, value := range map[string]string{ - "id": d.ID, "tenant_id": d.TenantID, "agent_id": d.AgentID, - "provider_id": d.ProviderID, "key_id": d.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if !lowerHex(d.IntentHash, 64) { - return fmt.Errorf("decision: intent_hash must be 64 lowercase hex characters") - } - switch d.Outcome { - case Allow, Deny, Constrain, ApprovalRequired: - default: - return fmt.Errorf("decision: invalid outcome %q", d.Outcome) - } - if len(d.Reasons) > 16 { - return fmt.Errorf("decision: at most 16 reasons are allowed") - } - for _, reason := range d.Reasons { - if err := validateText("reason", reason, 256, false); err != nil { - return err - } - } - if len(d.Constraints) > 32 { - return fmt.Errorf("decision: at most 32 constraints are allowed") - } - seen := make(map[string]struct{}, len(d.Constraints)) - for _, constraint := range d.Constraints { - if err := validateConstraint(constraint); err != nil { - return err - } - identity := constraint.Key + "\x00" + constraint.Operator - if _, exists := seen[identity]; exists { - return fmt.Errorf("decision: duplicate constraint %q/%q", constraint.Key, constraint.Operator) - } - seen[identity] = struct{}{} - } - if d.Outcome == Constrain && len(d.Constraints) == 0 { - return fmt.Errorf("decision: constrain outcome requires at least one constraint") - } - if d.Outcome != Constrain && len(d.Constraints) != 0 { - return fmt.Errorf("decision: constraints require the constrain outcome") - } - return validateWindow("decision", d.IssuedAt, d.ExpiresAt, MaxDecisionTTL) -} - -func (i Intent) Canonical() ([]byte, error) { - if err := i.Validate(); err != nil { - return nil, err - } - w := canonicalWriter{} - if i.MandateID != "" || i.Audience != "" || i.Purpose != "" { - w.string(IntentDomain + "/delegated") - } else { - w.string(IntentDomain) - } - w.u16(i.Version) - w.string(i.ID) - w.string(i.TenantID) - w.string(i.AgentID) - w.string(i.Action) - w.string(i.Resource) - w.string(i.PayloadHash) - w.string(string(i.Risk)) - w.i64(i.IssuedAt) - w.i64(i.ExpiresAt) - w.string(i.Nonce) - w.string(i.KeyID) - if i.MandateID != "" || i.Audience != "" || i.Purpose != "" { - w.string(i.MandateID) - w.string(i.Audience) - w.string(i.Purpose) - } - return w.Bytes(), nil -} - -func (d Decision) Canonical() ([]byte, error) { - if err := d.Validate(); err != nil { - return nil, err - } - w := canonicalWriter{} - w.string(DecisionDomain) - w.u16(d.Version) - w.string(d.ID) - w.string(d.IntentHash) - w.string(d.TenantID) - w.string(d.AgentID) - w.string(string(d.Outcome)) - w.u16(uint16(len(d.Reasons))) - for _, reason := range d.Reasons { - w.string(reason) - } - constraints := append([]Constraint(nil), d.Constraints...) - sort.Slice(constraints, func(a, b int) bool { - if constraints[a].Key != constraints[b].Key { - return constraints[a].Key < constraints[b].Key - } - if constraints[a].Operator != constraints[b].Operator { - return constraints[a].Operator < constraints[b].Operator - } - return constraints[a].Value < constraints[b].Value - }) - w.u16(uint16(len(constraints))) - for _, constraint := range constraints { - w.string(constraint.Key) - w.string(constraint.Operator) - w.string(constraint.Value) - } - w.u64(d.PolicyRevision) - w.u64(d.RevocationEpoch) - w.string(d.ProviderID) - w.i64(d.IssuedAt) - w.i64(d.ExpiresAt) - w.string(d.KeyID) - return w.Bytes(), nil -} - -func (i Intent) Hash() (string, error) { return hashCanonical(i.Canonical()) } - -func (d Decision) Hash() (string, error) { return hashCanonical(d.Canonical()) } - -func (i *Intent) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid intent private key length") - } - return i.SignWith(func(message []byte) ([]byte, error) { - return ed25519.Sign(privateKey, message), nil - }) -} - -func (i *Intent) SignWith(signer func([]byte) ([]byte, error)) error { - if signer == nil { - return fmt.Errorf("decision: intent signer is required") - } - canonical, err := i.Canonical() - if err != nil { - return err - } - signature, err := signer(canonical) - if err != nil { - return fmt.Errorf("decision: sign intent: %w", err) - } - if len(signature) != ed25519.SignatureSize { - return fmt.Errorf("decision: intent signer returned invalid signature length") - } - i.Signature = base64.StdEncoding.EncodeToString(signature) - return nil -} - -func (d *Decision) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid decision private key length") - } - return d.SignWith(func(message []byte) ([]byte, error) { - return ed25519.Sign(privateKey, message), nil - }) -} - -// SignWith signs the canonical decision using a caller-supplied Ed25519 -// operation. It lets a hardware-backed or remote key keep private material -// outside the authority process while preserving the wire signature format. -func (d *Decision) SignWith(signer func([]byte) ([]byte, error)) error { - if signer == nil { - return fmt.Errorf("decision: decision signer is required") - } - canonical, err := d.Canonical() - if err != nil { - return err - } - signature, err := signer(canonical) - if err != nil { - return fmt.Errorf("decision: sign decision: %w", err) - } - if len(signature) != ed25519.SignatureSize { - return fmt.Errorf("decision: decision signer returned invalid signature length") - } - d.Signature = base64.StdEncoding.EncodeToString(signature) - return nil -} - -func (i Intent) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := i.Canonical() - if err != nil { - return err - } - if err := verifyFresh("intent", i.IssuedAt, i.ExpiresAt, now); err != nil { - return err - } - return verifySignature("intent", publicKey, canonical, i.Signature) -} - -func (d Decision) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := d.Canonical() - if err != nil { - return err - } - if err := verifyFresh("decision", d.IssuedAt, d.ExpiresAt, now); err != nil { - return err - } - return verifySignature("decision", publicKey, canonical, d.Signature) -} - -// VerifyFor proves that d is a valid answer for this exact intent. This is the -// enforcement-point check; verifying the decision signature alone is not -// sufficient because a valid answer must not be replayed across actions, -// tenants, agents, or wider time windows. -func (d Decision) VerifyFor(intent Intent, publicKey ed25519.PublicKey, now time.Time) error { - if err := d.Verify(publicKey, now); err != nil { - return err - } - intentHash, err := intent.Hash() - if err != nil { - return err - } - if d.IntentHash != intentHash { - return fmt.Errorf("decision: decision is bound to a different intent") - } - if d.TenantID != intent.TenantID || d.AgentID != intent.AgentID { - return fmt.Errorf("decision: tenant or agent binding mismatch") - } - if d.ExpiresAt > intent.ExpiresAt { - return fmt.Errorf("decision: decision expiry expands intent authority") - } - if d.IssuedAt < intent.IssuedAt-int64(MaxClockSkew/time.Second) { - return fmt.Errorf("decision: decision predates intent") - } - return nil -} - -func hashCanonical(canonical []byte, err error) (string, error) { - if err != nil { - return "", err - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:]), nil -} - -func verifySignature(kind string, publicKey ed25519.PublicKey, canonical []byte, encoded string) error { - if len(publicKey) != ed25519.PublicKeySize { - return fmt.Errorf("decision: invalid %s public key length", kind) - } - signature, err := base64.StdEncoding.DecodeString(encoded) - if err != nil || len(signature) != ed25519.SignatureSize { - return fmt.Errorf("decision: invalid %s signature encoding", kind) - } - if !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("decision: %s signature verification failed", kind) - } - return nil -} - -func verifyFresh(kind string, issuedAt, expiresAt int64, now time.Time) error { - nowUnix := now.Unix() - if issuedAt > nowUnix+int64(MaxClockSkew/time.Second) { - return fmt.Errorf("decision: %s is not yet valid", kind) - } - if expiresAt < nowUnix { - return fmt.Errorf("decision: %s is expired", kind) - } - return nil -} - -func validateWindow(kind string, issuedAt, expiresAt int64, maxTTL time.Duration) error { - if issuedAt <= 0 || expiresAt <= issuedAt { - return fmt.Errorf("decision: invalid %s validity window", kind) - } - if expiresAt-issuedAt > int64(maxTTL/time.Second) { - return fmt.Errorf("decision: %s validity exceeds %s", kind, maxTTL) - } - return nil -} - -func validateConstraint(c Constraint) error { - if err := validateIdentifier("constraint key", c.Key); err != nil { - return err - } - switch c.Operator { - case "eq", "max", "min", "one_of", "redact", "require": - default: - return fmt.Errorf("decision: unsupported constraint operator %q", c.Operator) - } - return validateText("constraint value", c.Value, 1024, true) -} - -func validateIdentifier(name, value string) error { - if len(value) == 0 || len(value) > 128 || !utf8.ValidString(value) { - return fmt.Errorf("decision: invalid %s", name) - } - for index, r := range value { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || - (index > 0 && strings.ContainsRune("._:/@-", r)) { - continue - } - return fmt.Errorf("decision: invalid %s", name) - } - return nil -} - -func validAction(value string) bool { - if len(value) == 0 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' { - return false - } - for _, r := range value[1:] { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || strings.ContainsRune("._-", r) { - continue - } - return false - } - return true -} - -func validateText(name, value string, maxBytes int, allowEmpty bool) error { - if (!allowEmpty && value == "") || len(value) > maxBytes || !utf8.ValidString(value) { - return fmt.Errorf("decision: invalid %s", name) - } - for _, r := range value { - if r < 0x20 || r == 0x7f { - return fmt.Errorf("decision: %s contains control characters", name) - } - } - return nil -} - -func lowerHex(value string, length int) bool { - if len(value) != length { - return false - } - for _, c := range value { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { - return false - } - } - return true -} - -type canonicalWriter struct{ bytes.Buffer } - -func (w *canonicalWriter) string(value string) { - _ = binary.Write(&w.Buffer, binary.BigEndian, uint32(len(value))) - _, _ = w.WriteString(value) -} - -func (w *canonicalWriter) u16(value uint16) { - _ = binary.Write(&w.Buffer, binary.BigEndian, value) -} - -func (w *canonicalWriter) u64(value uint64) { - _ = binary.Write(&w.Buffer, binary.BigEndian, value) -} - -func (w *canonicalWriter) i64(value int64) { - _ = binary.Write(&w.Buffer, binary.BigEndian, value) -} diff --git a/decision/decision_test.go b/decision/decision_test.go deleted file mode 100644 index 8ad4883..0000000 --- a/decision/decision_test.go +++ /dev/null @@ -1,198 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "crypto/rand" - "encoding/hex" - "strings" - "testing" - "time" -) - -func TestValidityWindowRejectsOverflowSizedTTL(t *testing.T) { - intent := testIntent(t, time.Unix(1785500000, 0)) - intent.IssuedAt = 1 - intent.ExpiresAt = int64(^uint64(0) >> 1) - if err := intent.Validate(); err == nil { - t.Fatal("overflow-sized intent TTL was accepted") - } -} - -func TestConformanceVectorV1(t *testing.T) { - seed := make([]byte, ed25519.SeedSize) - for i := range seed { - seed[i] = byte(i) - } - privateKey := ed25519.NewKeyFromSeed(seed) - intent := Intent{ - Version: SchemaVersion, ID: "intent-vector-1", TenantID: "tenant-example", AgentID: "agent-7", - Action: "message.send", Resource: "agent/9", PayloadHash: HashPayload([]byte("hello")), - Risk: RiskMedium, IssuedAt: 1785500000, ExpiresAt: 1785500120, - Nonce: "000102030405060708090a0b0c0d0e0f", KeyID: "agent-key-1", - } - if err := intent.Sign(privateKey); err != nil { - t.Fatal(err) - } - intentCanonical, _ := intent.Canonical() - intentHash, _ := intent.Hash() - decision := Decision{ - Version: SchemaVersion, ID: "decision-vector-1", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: Constrain, - Reasons: []string{"recipient policy"}, - Constraints: []Constraint{{Key: "bytes", Operator: "max", Value: "4096"}}, - PolicyRevision: 12, RevocationEpoch: 3, ProviderID: "local-example", - IssuedAt: 1785500001, ExpiresAt: 1785500060, KeyID: "decision-key-1", - } - if err := decision.Sign(privateKey); err != nil { - t.Fatal(err) - } - decisionCanonical, _ := decision.Canonical() - vectors := map[string][2]string{ - "intent canonical": { - hex.EncodeToString(intentCanonical), - "0000000f70696c6f742d696e74656e742d763100010000000f696e74656e742d766563746f722d310000000e74656e616e742d6578616d706c65000000076167656e742d370000000c6d6573736167652e73656e64000000076167656e742f390000004032636632346462613566623061333065323665383362326163356239653239653162313631653563316661373432356537333034333336323933386239383234000000066d656469756d000000006a6c9160000000006a6c91d80000002030303031303230333034303530363037303830393061306230633064306530660000000b6167656e742d6b65792d31", - }, - "intent hash": {intentHash, "66409791c5dcfe0ccada980a9140f62207539af15b3c8040a7c585fe1907f089"}, - "intent signature": {intent.Signature, "9oDEwdc6CIEQu45oGHa2zXk9H5G5ssAHI5xp1nqcvjX8+XR7G+R2MIBFKfECGt+xY/nuLCbFUFWbNF1iKZsaCA=="}, - "decision canonical": { - hex.EncodeToString(decisionCanonical), - "0000001170696c6f742d6465636973696f6e2d76310001000000116465636973696f6e2d766563746f722d3100000040363634303937393163356463666530636361646139383061393134306636323230373533396166313562336338303430613763353835666531393037663038390000000e74656e616e742d6578616d706c65000000076167656e742d3700000009636f6e73747261696e000100000010726563697069656e7420706f6c6963790001000000056279746573000000036d61780000000434303936000000000000000c00000000000000030000000d6c6f63616c2d6578616d706c65000000006a6c9161000000006a6c919c0000000e6465636973696f6e2d6b65792d31", - }, - "decision signature": {decision.Signature, "uVz8UkyZDi3KtYHZoE15fpqXOXNz+cfdu3hWAIMTqev6LJ4cChZA58HzlU0riYqOokAyxZGq9pkSoYqAfSnSDQ=="}, - } - for name, vector := range vectors { - if vector[0] != vector[1] { - t.Errorf("%s = %q, want %q", name, vector[0], vector[1]) - } - } -} - -func testIntent(t *testing.T, now time.Time) Intent { - t.Helper() - nonce, err := NewNonce() - if err != nil { - t.Fatal(err) - } - return Intent{ - Version: SchemaVersion, - ID: "intent-001", - TenantID: "tenant-acme", - AgentID: "agent-buyer-1", - Action: "wallet.pay", - Resource: "invoice/inv-42", - PayloadHash: HashPayload([]byte(`{"amount":"25.00","asset":"USDC"}`)), - Risk: RiskHigh, - IssuedAt: now.Unix(), - ExpiresAt: now.Add(2 * time.Minute).Unix(), - Nonce: nonce, - KeyID: "agent-key-7", - } -} - -func TestSignedIntentRejectsTamperAndExpiry(t *testing.T) { - t.Parallel() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - if err := intent.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := intent.Verify(publicKey, now); err != nil { - t.Fatalf("valid intent rejected: %v", err) - } - tampered := intent - tampered.Action = "wallet.topup" - if err := tampered.Verify(publicKey, now); err == nil || !strings.Contains(err.Error(), "signature") { - t.Fatalf("tampered intent error = %v", err) - } - if err := intent.Verify(publicKey, time.Unix(intent.ExpiresAt+1, 0)); err == nil || !strings.Contains(err.Error(), "expired") { - t.Fatalf("expired intent error = %v", err) - } -} - -func TestDecisionVerifyForBindsIntentTenantAgentAndTime(t *testing.T) { - t.Parallel() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - intentHash, err := intent.Hash() - if err != nil { - t.Fatal(err) - } - decision := Decision{ - Version: SchemaVersion, - ID: "decision-001", - IntentHash: intentHash, - TenantID: intent.TenantID, - AgentID: intent.AgentID, - Outcome: Constrain, - Reasons: []string{"amount is within mandate"}, - Constraints: []Constraint{{Key: "amount_usdc", Operator: "max", Value: "25.00"}}, - PolicyRevision: 42, - RevocationEpoch: 3, - ProviderID: "pilot-managed-eu1", - IssuedAt: now.Unix(), - ExpiresAt: now.Add(time.Minute).Unix(), - KeyID: "decision-key-9", - } - if err := decision.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := decision.VerifyFor(intent, publicKey, now); err != nil { - t.Fatalf("valid decision rejected: %v", err) - } - - otherTenant := intent - otherTenant.TenantID = "tenant-other" - if err := decision.VerifyFor(otherTenant, publicKey, now); err == nil { - t.Fatal("cross-tenant decision replay accepted") - } - wider := decision - wider.ExpiresAt = intent.ExpiresAt + 1 - if err := wider.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := wider.VerifyFor(intent, publicKey, now); err == nil || !strings.Contains(err.Error(), "expands") { - t.Fatalf("authority-expanding expiry error = %v", err) - } -} - -func TestConstraintOrderIsCanonicalAndUnknownOperatorFails(t *testing.T) { - t.Parallel() - _, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0) - base := Decision{ - Version: SchemaVersion, ID: "decision-order", IntentHash: strings.Repeat("a", 64), - TenantID: "tenant-acme", AgentID: "agent-one", Outcome: Constrain, - Constraints: []Constraint{ - {Key: "recipient", Operator: "one_of", Value: "vendor-a,vendor-b"}, - {Key: "amount", Operator: "max", Value: "100"}, - }, - ProviderID: "local", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-1", - } - if err := base.Sign(privateKey); err != nil { - t.Fatal(err) - } - reordered := base - reordered.Constraints = []Constraint{base.Constraints[1], base.Constraints[0]} - baseCanonical, _ := base.Canonical() - reorderedCanonical, _ := reordered.Canonical() - if string(baseCanonical) != string(reorderedCanonical) { - t.Fatal("constraint reordering changed canonical form") - } - reordered.Constraints[0].Operator = "execute" - if err := reordered.Validate(); err == nil || !strings.Contains(err.Error(), "unsupported") { - t.Fatalf("unknown operator error = %v", err) - } -} diff --git a/decision/disclosure.go b/decision/disclosure.go deleted file mode 100644 index baf3f4f..0000000 --- a/decision/disclosure.go +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "fmt" - "mime" - "path/filepath" - "strings" - "unicode/utf8" -) - -const ( - // DisclosureBindingVersion preserves the original V1 canonical bytes. - // Callers that do not need retention metadata should continue to use it. - DisclosureBindingVersion uint16 = 1 - // DisclosureBindingRetentionVersion adds a policy-selected retention class - // without changing V1 Intent or disclosure hashes. - DisclosureBindingRetentionVersion uint16 = 2 - DisclosureBindingDomain = "pilot-disclosure-binding-v1" - DisclosureBindingRetentionDomain = "pilot-disclosure-binding-v2" -) - -// DisclosureBinding is canonical privacy metadata for a governed disclosure. -// It deliberately carries hashes and declared characteristics, never the -// application body. V1 Intents bind it by placing Hash() in PayloadHash; this -// leaves historical Intent and Receipt canonicalization unchanged. -type DisclosureBinding struct { - Version uint16 `json:"version"` - ContentHash string `json:"content_hash"` - DeclaredBytes uint64 `json:"declared_bytes"` - ContentType string `json:"content_type"` - Labels []string `json:"labels"` - Recipient string `json:"recipient"` - Purpose string `json:"purpose"` - Residency string `json:"residency"` - Filename string `json:"filename,omitempty"` - TransferID string `json:"transfer_id,omitempty"` - // RetentionClass is available only in V2. It is an opaque, tenant-defined - // class (for example "finance-7y"), not an unsupported claim that the - // receiver has already performed deletion or legal-hold operations. - RetentionClass string `json:"retention_class,omitempty"` -} - -func (binding DisclosureBinding) Validate() error { - switch binding.Version { - case DisclosureBindingVersion: - if binding.RetentionClass != "" { - return fmt.Errorf("decision: disclosure retention_class requires version %d", DisclosureBindingRetentionVersion) - } - case DisclosureBindingRetentionVersion: - if !validDisclosureLabel(binding.RetentionClass) { - return fmt.Errorf("decision: invalid disclosure retention_class %q", binding.RetentionClass) - } - default: - return fmt.Errorf("decision: disclosure binding version %d is unsupported", binding.Version) - } - if !lowerHex(binding.ContentHash, 64) { - return fmt.Errorf("decision: disclosure content_hash must be 64 lowercase hex characters") - } - if err := validateDisclosureContentType(binding.ContentType); err != nil { - return err - } - if len(binding.Labels) == 0 || len(binding.Labels) > 16 { - return fmt.Errorf("decision: disclosure requires 1-16 labels") - } - for index, label := range binding.Labels { - if !validDisclosureLabel(label) { - return fmt.Errorf("decision: invalid disclosure label %q", label) - } - if index > 0 && binding.Labels[index-1] >= label { - return fmt.Errorf("decision: disclosure labels must be strictly sorted") - } - } - if err := validateText("disclosure recipient", binding.Recipient, 256, false); err != nil { - return err - } - if err := validateText("disclosure purpose", binding.Purpose, 256, false); err != nil { - return err - } - if !validDisclosureResidency(binding.Residency) { - return fmt.Errorf("decision: invalid disclosure residency %q", binding.Residency) - } - if binding.Filename != "" && (!utf8.ValidString(binding.Filename) || len(binding.Filename) > 256 || filepath.Base(binding.Filename) != binding.Filename || strings.ContainsAny(binding.Filename, "/\\")) { - return fmt.Errorf("decision: invalid disclosure filename") - } - if binding.TransferID != "" { - if err := validateText("disclosure transfer_id", binding.TransferID, 256, false); err != nil { - return err - } - } - return nil -} - -func (binding DisclosureBinding) Canonical() ([]byte, error) { - if err := binding.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - if binding.Version == DisclosureBindingVersion { - writer.string(DisclosureBindingDomain) - } else { - writer.string(DisclosureBindingRetentionDomain) - } - writer.u16(binding.Version) - writer.string(binding.ContentHash) - writer.u64(binding.DeclaredBytes) - writer.string(binding.ContentType) - writer.u16(uint16(len(binding.Labels))) - for _, label := range binding.Labels { - writer.string(label) - } - writer.string(binding.Recipient) - writer.string(binding.Purpose) - writer.string(binding.Residency) - writer.string(binding.Filename) - writer.string(binding.TransferID) - if binding.Version == DisclosureBindingRetentionVersion { - writer.string(binding.RetentionClass) - } - return writer.Bytes(), nil -} - -func (binding DisclosureBinding) Hash() (string, error) { return hashCanonical(binding.Canonical()) } - -// VerifyIntent proves that the caller signed this exact disclosure binding -// into a legacy V1 Intent without changing that Intent's canonical bytes. -func (binding DisclosureBinding) VerifyIntent(intent Intent) error { - if err := binding.Validate(); err != nil { - return err - } - if err := intent.Validate(); err != nil { - return err - } - hash, err := binding.Hash() - if err != nil { - return err - } - if intent.PayloadHash != hash { - return fmt.Errorf("decision: intent does not bind this disclosure") - } - if intent.Audience != binding.Recipient || intent.Purpose != binding.Purpose { - return fmt.Errorf("decision: intent disclosure audience or purpose mismatch") - } - return nil -} - -func validateDisclosureContentType(value string) error { - if value == "" || len(value) > 128 || value != strings.ToLower(value) { - return fmt.Errorf("decision: invalid disclosure content_type") - } - mediaType, parameters, err := mime.ParseMediaType(value) - if err != nil || len(parameters) != 0 || mediaType != value || !strings.Contains(mediaType, "/") { - return fmt.Errorf("decision: invalid disclosure content_type") - } - return nil -} - -func validDisclosureLabel(value string) bool { - if len(value) == 0 || len(value) > 64 { - return false - } - for index, character := range value { - if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' && index > 0 && index+1 < len(value)) { - return false - } - } - return value[0] != '-' && value[len(value)-1] != '-' -} - -func validDisclosureResidency(value string) bool { - if len(value) == 0 || len(value) > 64 { - return false - } - for _, character := range value { - if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-') { - return false - } - } - return value[0] != '-' && value[len(value)-1] != '-' -} diff --git a/decision/disclosure_test.go b/decision/disclosure_test.go deleted file mode 100644 index 68a5ee2..0000000 --- a/decision/disclosure_test.go +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "crypto/rand" - "strings" - "testing" - "time" -) - -func validDisclosureBinding() DisclosureBinding { - return DisclosureBinding{ - Version: DisclosureBindingVersion, ContentHash: strings.Repeat("a", 64), DeclaredBytes: 42, - ContentType: "application/json", Labels: []string{"finance", "pii"}, Recipient: "agent:finance", - Purpose: "invoice-payment", Residency: "eu-west-1", Filename: "invoice.json", TransferID: "transfer-1", - } -} - -func TestDisclosureBindingCanonicalAndIntentBinding(t *testing.T) { - binding := validDisclosureBinding() - hash, err := binding.Hash() - if err != nil { - t.Fatal(err) - } - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Now().UTC() - intent := Intent{ - Version: SchemaVersion, ID: "intent-disclosure-1", TenantID: "tenant-a", AgentID: "agent-a", Action: "file.share", - Resource: "agent:finance/inbox", Audience: binding.Recipient, Purpose: binding.Purpose, PayloadHash: hash, Risk: RiskHigh, - IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), Nonce: strings.Repeat("b", 32), KeyID: "intent-key-1", - } - if err := intent.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := intent.Verify(publicKey, now); err != nil { - t.Fatal(err) - } - if err := binding.VerifyIntent(intent); err != nil { - t.Fatal(err) - } - tampered := binding - tampered.Residency = "us-east-1" - if err := tampered.VerifyIntent(intent); err == nil { - t.Fatal("residency mutation retained intent binding") - } -} - -func TestDisclosureBindingV2RetentionClassBindsWithoutChangingV1(t *testing.T) { - v1 := validDisclosureBinding() - v1Canonical, err := v1.Canonical() - if err != nil { - t.Fatal(err) - } - v2 := v1 - v2.Version = DisclosureBindingRetentionVersion - v2.RetentionClass = "finance-7y" - v2Canonical, err := v2.Canonical() - if err != nil { - t.Fatal(err) - } - if string(v1Canonical) == string(v2Canonical) { - t.Fatal("V2 retention metadata reused V1 canonical bytes") - } - hash, err := v2.Hash() - if err != nil { - t.Fatal(err) - } - now := time.Now().UTC() - intent := Intent{ - Version: SchemaVersion, ID: "intent-disclosure-v2", TenantID: "tenant-a", AgentID: "agent-a", Action: "file.share", - Resource: "agent:finance/inbox", Audience: v2.Recipient, Purpose: v2.Purpose, PayloadHash: hash, Risk: RiskHigh, - IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), Nonce: strings.Repeat("c", 32), KeyID: "intent-key-1", - } - if err := v2.VerifyIntent(intent); err != nil { - t.Fatal(err) - } - mutated := v2 - mutated.RetentionClass = "finance-30d" - if err := mutated.VerifyIntent(intent); err == nil { - t.Fatal("retention class mutation retained intent binding") - } - v1.RetentionClass = "finance-7y" - if err := v1.Validate(); err == nil || !strings.Contains(err.Error(), "requires version") { - t.Fatalf("V1 retention class validation error=%v", err) - } -} - -func TestDisclosureBindingFailsClosed(t *testing.T) { - for name, mutate := range map[string]func(*DisclosureBinding){ - "unsorted labels": func(binding *DisclosureBinding) { binding.Labels = []string{"pii", "finance"} }, - "duplicate labels": func(binding *DisclosureBinding) { binding.Labels = []string{"finance", "finance"} }, - "parameterized content type": func(binding *DisclosureBinding) { binding.ContentType = "application/json; charset=utf-8" }, - "upper content type": func(binding *DisclosureBinding) { binding.ContentType = "Application/json" }, - "bad residency": func(binding *DisclosureBinding) { binding.Residency = "EU West" }, - "path filename": func(binding *DisclosureBinding) { binding.Filename = "private/invoice.json" }, - } { - t.Run(name, func(t *testing.T) { - binding := validDisclosureBinding() - mutate(&binding) - if err := binding.Validate(); err == nil { - t.Fatalf("invalid disclosure binding accepted: %+v", binding) - } - }) - } -} diff --git a/decision/enforcer.go b/decision/enforcer.go deleted file mode 100644 index 7818aa5..0000000 --- a/decision/enforcer.go +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "crypto/ed25519" - "fmt" - "time" -) - -// TrustStore resolves keys within an explicit tenant scope and supplies the -// minimum state an enforcement point is willing to accept. -type TrustStore interface { - IntentKey(ctx context.Context, tenantID, agentID, keyID string) (ed25519.PublicKey, error) - DecisionKey(ctx context.Context, tenantID, keyID string) (ed25519.PublicKey, error) - MinimumState(ctx context.Context, tenantID string) (policyRevision, revocationEpoch uint64, err error) -} - -// AuthorityCeiling checks the tenant's local mandate and deterministic policy. -// A provider decision is usable only when this local ceiling also permits it. -type AuthorityCeiling interface { - Check(ctx context.Context, intent Intent, decision Decision) error -} - -// DisclosureCeiling is the strict local policy extension used when an Intent -// binds a DisclosureBinding. A receiver must not accept typed metadata using a -// ceiling that can only inspect the legacy Intent fields, because that would -// silently drop label, content-type, recipient, purpose, or residency rules. -type DisclosureCeiling interface { - CheckDisclosure(ctx context.Context, intent Intent, result Decision, disclosure DisclosureBinding) error -} - -// Enforcer performs the complete provider-independent authorization sequence. -// It intentionally has no billing dependency. -type Enforcer struct { - Provider Authorizer - Trust TrustStore - Ceiling AuthorityCeiling - Now func() time.Time -} - -func (e *Enforcer) Authorize(ctx context.Context, intent Intent) (Decision, error) { - if e == nil || e.Provider == nil || e.Trust == nil || e.Ceiling == nil { - return Decision{}, fmt.Errorf("decision: enforcer is missing provider, trust store, or local ceiling") - } - result, err := e.Provider.Authorize(ctx, intent) - if err != nil { - return Decision{}, fmt.Errorf("decision: provider unavailable or refused request: %w", err) - } - if err := e.Verify(ctx, intent, result); err != nil { - return Decision{}, err - } - return result, nil -} - -// AuthorizeDisclosure requests and locally verifies a Decision for a -// disclosure-bound Intent. Both the provider and local ceiling must opt in to -// the explicit disclosure interfaces; falling back to legacy authorization is -// unsafe because it would ignore typed policy inputs. -func (e *Enforcer) AuthorizeDisclosure(ctx context.Context, intent Intent, disclosure DisclosureBinding) (Decision, error) { - if e == nil || e.Provider == nil || e.Trust == nil || e.Ceiling == nil { - return Decision{}, fmt.Errorf("decision: enforcer is missing provider, trust store, or local ceiling") - } - if err := disclosure.VerifyIntent(intent); err != nil { - return Decision{}, err - } - provider, supported := e.Provider.(DisclosureAuthorizer) - if !supported { - return Decision{}, fmt.Errorf("decision: provider does not support disclosure binding") - } - result, err := provider.AuthorizeDisclosure(ctx, intent, disclosure) - if err != nil { - return Decision{}, fmt.Errorf("decision: provider unavailable or refused request: %w", err) - } - if err := e.VerifyDisclosure(ctx, intent, result, disclosure); err != nil { - return Decision{}, err - } - return result, nil -} - -// Verify applies the local trust, freshness, state-floor, and deterministic -// ceiling checks to a signed decision obtained through a non-standard path, -// such as a completed long-running approval workflow. The caller remains -// responsible for obtaining the decision and for resource-side idempotency. -func (e *Enforcer) Verify(ctx context.Context, intent Intent, result Decision) error { - return e.verify(ctx, intent, result, nil) -} - -// VerifyDisclosure applies the normal signed decision checks and an explicit -// disclosure-aware local ceiling. It rejects a legacy ceiling rather than -// treating its non-disclosure check as evidence that a typed rule was applied. -func (e *Enforcer) VerifyDisclosure(ctx context.Context, intent Intent, result Decision, disclosure DisclosureBinding) error { - if err := disclosure.VerifyIntent(intent); err != nil { - return err - } - return e.verify(ctx, intent, result, &disclosure) -} - -func (e *Enforcer) verify(ctx context.Context, intent Intent, result Decision, disclosure *DisclosureBinding) error { - if e == nil || e.Trust == nil || e.Ceiling == nil { - return fmt.Errorf("decision: enforcer is missing trust store or local ceiling") - } - now := time.Now() - if e.Now != nil { - now = e.Now() - } - intentKey, err := e.Trust.IntentKey(ctx, intent.TenantID, intent.AgentID, intent.KeyID) - if err != nil { - return fmt.Errorf("decision: resolve intent key: %w", err) - } - if err := intent.Verify(intentKey, now); err != nil { - return err - } - decisionKey, err := e.Trust.DecisionKey(ctx, intent.TenantID, result.KeyID) - if err != nil { - return fmt.Errorf("decision: resolve decision key: %w", err) - } - if err := result.VerifyFor(intent, decisionKey, now); err != nil { - return err - } - minimumPolicy, minimumRevocation, err := e.Trust.MinimumState(ctx, intent.TenantID) - if err != nil { - return fmt.Errorf("decision: resolve minimum authority state: %w", err) - } - if result.PolicyRevision < minimumPolicy { - return fmt.Errorf("decision: stale policy revision %d, require at least %d", result.PolicyRevision, minimumPolicy) - } - if result.RevocationEpoch < minimumRevocation { - return fmt.Errorf("decision: stale revocation epoch %d, require at least %d", result.RevocationEpoch, minimumRevocation) - } - if disclosure != nil { - ceiling, supported := e.Ceiling.(DisclosureCeiling) - if !supported { - return fmt.Errorf("decision: local authority ceiling does not support disclosure binding") - } - if err := ceiling.CheckDisclosure(ctx, intent, result, *disclosure); err != nil { - return fmt.Errorf("decision: local authority ceiling: %w", err) - } - return nil - } - if err := e.Ceiling.Check(ctx, intent, result); err != nil { - return fmt.Errorf("decision: local authority ceiling: %w", err) - } - return nil -} diff --git a/decision/enforcer_test.go b/decision/enforcer_test.go deleted file mode 100644 index e00d6a1..0000000 --- a/decision/enforcer_test.go +++ /dev/null @@ -1,224 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "errors" - "strings" - "testing" - "time" -) - -type authorizerFunc func(context.Context, Intent) (Decision, error) - -func (f authorizerFunc) Authorize(ctx context.Context, intent Intent) (Decision, error) { - return f(ctx, intent) -} - -type testTrustStore struct { - intentKey ed25519.PublicKey - decisionKey ed25519.PublicKey - policyRevision uint64 - revocationEpoch uint64 -} - -func (s testTrustStore) IntentKey(_ context.Context, _, _, _ string) (ed25519.PublicKey, error) { - return s.intentKey, nil -} -func (s testTrustStore) DecisionKey(_ context.Context, _, _ string) (ed25519.PublicKey, error) { - return s.decisionKey, nil -} -func (s testTrustStore) MinimumState(_ context.Context, _ string) (uint64, uint64, error) { - return s.policyRevision, s.revocationEpoch, nil -} - -type ceilingFunc func(context.Context, Intent, Decision) error - -func (f ceilingFunc) Check(ctx context.Context, intent Intent, result Decision) error { - return f(ctx, intent, result) -} - -type disclosureCeilingFunc func(context.Context, Intent, Decision, DisclosureBinding) error - -func (ceiling disclosureCeilingFunc) Check(context.Context, Intent, Decision) error { - return errors.New("plain ceiling method should not be used") -} - -func (ceiling disclosureCeilingFunc) CheckDisclosure(ctx context.Context, intent Intent, result Decision, disclosure DisclosureBinding) error { - return ceiling(ctx, intent, result, disclosure) -} - -func TestEnforcerVerifiesBothSignaturesStateAndLocalCeiling(t *testing.T) { - t.Parallel() - intentPublic, intentPrivate, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - decisionPublic, decisionPrivate, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - if err := intent.Sign(intentPrivate); err != nil { - t.Fatal(err) - } - intentHash, err := intent.Hash() - if err != nil { - t.Fatal(err) - } - result := Decision{ - Version: SchemaVersion, ID: "decision-enforcer", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: Allow, - PolicyRevision: 8, RevocationEpoch: 5, ProviderID: "managed", - IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-key-1", - } - if err := result.Sign(decisionPrivate); err != nil { - t.Fatal(err) - } - ceilingCalled := false - enforcer := Enforcer{ - Provider: authorizerFunc(func(context.Context, Intent) (Decision, error) { return result, nil }), - Trust: testTrustStore{ - intentKey: intentPublic, decisionKey: decisionPublic, policyRevision: 8, revocationEpoch: 5, - }, - Ceiling: ceilingFunc(func(context.Context, Intent, Decision) error { - ceilingCalled = true - return nil - }), - Now: func() time.Time { return now }, - } - if _, err := enforcer.Authorize(context.Background(), intent); err != nil { - t.Fatal(err) - } - if !ceilingCalled { - t.Fatal("local authority ceiling was not consulted") - } -} - -func TestEnforcerDisclosureVerificationRequiresDisclosureAwareCeiling(t *testing.T) { - intentPublic, intentPrivate, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - decisionPublic, decisionPrivate, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0) - disclosure := DisclosureBinding{ - Version: DisclosureBindingVersion, ContentHash: HashPayload([]byte("invoice")), DeclaredBytes: 7, - ContentType: "application/pdf", Labels: []string{"finance", "pii"}, Recipient: "agent:finance", - Purpose: "invoice-payment", Residency: "eu-west-1", Filename: "invoice.pdf", - } - disclosureHash, err := disclosure.Hash() - if err != nil { - t.Fatal(err) - } - intent := testIntent(t, now) - intent.Audience, intent.Purpose, intent.PayloadHash = disclosure.Recipient, disclosure.Purpose, disclosureHash - if err := intent.Sign(intentPrivate); err != nil { - t.Fatal(err) - } - intentHash, err := intent.Hash() - if err != nil { - t.Fatal(err) - } - result := Decision{ - Version: SchemaVersion, ID: "disclosure-decision", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, - Outcome: Allow, PolicyRevision: 8, RevocationEpoch: 5, ProviderID: "managed", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-key-1", - } - if err := result.Sign(decisionPrivate); err != nil { - t.Fatal(err) - } - trust := testTrustStore{intentKey: intentPublic, decisionKey: decisionPublic, policyRevision: 8, revocationEpoch: 5} - legacy := Enforcer{Trust: trust, Ceiling: ceilingFunc(func(context.Context, Intent, Decision) error { return nil }), Now: func() time.Time { return now }} - if err := legacy.VerifyDisclosure(context.Background(), intent, result, disclosure); err == nil || !strings.Contains(err.Error(), "does not support disclosure") { - t.Fatalf("legacy ceiling accepted disclosure: %v", err) - } - called := false - aware := Enforcer{Trust: trust, Ceiling: disclosureCeilingFunc(func(_ context.Context, got Intent, gotResult Decision, gotDisclosure DisclosureBinding) error { - called = true - if got.ID != intent.ID || gotResult.ID != result.ID || gotDisclosure.Residency != disclosure.Residency { - t.Fatalf("disclosure ceiling inputs intent=%+v result=%+v disclosure=%+v", got, gotResult, gotDisclosure) - } - return nil - }), Now: func() time.Time { return now }} - if err := aware.VerifyDisclosure(context.Background(), intent, result, disclosure); err != nil || !called { - t.Fatalf("disclosure ceiling err=%v called=%t", err, called) - } -} - -func TestEnforcerFailsClosedOnProviderCeilingAndStaleState(t *testing.T) { - t.Parallel() - intentPublic, intentPrivate, _ := ed25519.GenerateKey(rand.Reader) - decisionPublic, decisionPrivate, _ := ed25519.GenerateKey(rand.Reader) - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - if err := intent.Sign(intentPrivate); err != nil { - t.Fatal(err) - } - intentHash, _ := intent.Hash() - result := Decision{ - Version: SchemaVersion, ID: "decision-stale", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: Allow, - PolicyRevision: 4, RevocationEpoch: 2, ProviderID: "managed", - IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-key-1", - } - if err := result.Sign(decisionPrivate); err != nil { - t.Fatal(err) - } - - base := Enforcer{ - Provider: authorizerFunc(func(context.Context, Intent) (Decision, error) { return result, nil }), - Trust: testTrustStore{ - intentKey: intentPublic, decisionKey: decisionPublic, policyRevision: 5, revocationEpoch: 2, - }, - Ceiling: ceilingFunc(func(context.Context, Intent, Decision) error { return nil }), - Now: func() time.Time { return now }, - } - if _, err := base.Authorize(context.Background(), intent); err == nil || !strings.Contains(err.Error(), "stale policy") { - t.Fatalf("stale state error = %v", err) - } - - base.Trust = testTrustStore{intentKey: intentPublic, decisionKey: decisionPublic} - base.Ceiling = ceilingFunc(func(context.Context, Intent, Decision) error { return errors.New("mandate exceeded") }) - if _, err := base.Authorize(context.Background(), intent); err == nil || !strings.Contains(err.Error(), "mandate exceeded") { - t.Fatalf("ceiling error = %v", err) - } - - base.Provider = authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{}, errors.New("offline") }) - if got, err := base.Authorize(context.Background(), intent); err == nil || got.Outcome == Allow { - t.Fatalf("provider outage returned %+v, err=%v", got, err) - } -} - -func TestEnforcerVerifiesExternalWorkflowDecisionWithoutCallingProvider(t *testing.T) { - t.Parallel() - intentPublic, intentPrivate, _ := ed25519.GenerateKey(rand.Reader) - decisionPublic, decisionPrivate, _ := ed25519.GenerateKey(rand.Reader) - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - if err := intent.Sign(intentPrivate); err != nil { - t.Fatal(err) - } - intentHash, _ := intent.Hash() - result := Decision{ - Version: SchemaVersion, ID: "workflow-external", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, - Outcome: Constrain, Constraints: []Constraint{{Key: "amount", Operator: "max", Value: "100"}}, - PolicyRevision: 3, RevocationEpoch: 2, ProviderID: "managed", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-key-1", - } - if err := result.Sign(decisionPrivate); err != nil { - t.Fatal(err) - } - enforcer := Enforcer{ - Trust: testTrustStore{intentKey: intentPublic, decisionKey: decisionPublic, policyRevision: 3, revocationEpoch: 2}, - Ceiling: ceilingFunc(func(context.Context, Intent, Decision) error { return nil }), Now: func() time.Time { return now }, - } - if err := enforcer.Verify(context.Background(), intent, result); err != nil { - t.Fatal(err) - } -} diff --git a/decision/evaluation_journal.go b/decision/evaluation_journal.go deleted file mode 100644 index c8e9f68..0000000 --- a/decision/evaluation_journal.go +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "sync" - - "github.com/pilot-protocol/common/fsutil" -) - -// EvaluationJournalStore is the durable observation/read contract used by -// semantic enforcement, usage delivery, and the operator console. File-backed -// single-node deployments and shared PostgreSQL fleets implement the same -// interface. -type EvaluationJournalStore interface { - EvaluationObserver - EvaluationRecords(context.Context, int) ([]EvaluationRecord, error) -} - -// EvaluationLookupStore is the indexed correlation contract used by an action -// trace. It remains optional so external append-only journals stay compatible. -type EvaluationLookupStore interface { - EvaluationRecordForIntent(context.Context, string, string) (EvaluationRecord, bool, error) -} - -type EvaluationJournal struct { - path string - mu sync.Mutex - seen map[string]EvaluationRecord - records []EvaluationRecord -} - -func OpenEvaluationJournal(path string) (*EvaluationJournal, error) { - if path == "" { - return nil, fmt.Errorf("decision: evaluation journal path is required") - } - absolute, err := filepath.Abs(filepath.Clean(path)) - if err != nil { - return nil, err - } - if info, statErr := os.Lstat(absolute); statErr == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { - return nil, fmt.Errorf("decision: evaluation journal must be an owner-only regular file") - } - } else if !os.IsNotExist(statErr) { - return nil, statErr - } - if err := os.MkdirAll(filepath.Dir(absolute), 0o700); err != nil { - return nil, err - } - journal := &EvaluationJournal{path: absolute, seen: make(map[string]EvaluationRecord)} - if err := journal.load(); err != nil { - return nil, err - } - return journal, nil -} - -func (journal *EvaluationJournal) RecordEvaluation(ctx context.Context, record EvaluationRecord) error { - if journal == nil || journal.path == "" { - return fmt.Errorf("decision: evaluation journal is not initialized") - } - if err := ctx.Err(); err != nil { - return err - } - if err := record.Validate(); err != nil { - return err - } - body, err := json.Marshal(record) - if err != nil { - return err - } - journal.mu.Lock() - defer journal.mu.Unlock() - if existing, exists := journal.seen[record.ID]; exists { - if !evaluationRecordsEqual(existing, record) { - return fmt.Errorf("decision: conflicting evaluation usage unit %q", record.ID) - } - return nil - } - if err := fsutil.AppendSync(journal.path, append(body, '\n')); err != nil { - return fmt.Errorf("decision: append evaluation journal: %w", err) - } - journal.seen[record.ID] = record - journal.records = append(journal.records, record) - return nil -} - -func (journal *EvaluationJournal) Records() []EvaluationRecord { - records, _ := journal.EvaluationRecords(context.Background(), 0) - return records -} - -// EvaluationRecords returns records in chronological order. A zero limit -// returns the complete journal for backwards-compatible single-node usage; -// positive limits return the most recent bounded window. -func (journal *EvaluationJournal) EvaluationRecords(ctx context.Context, limit int) ([]EvaluationRecord, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if journal == nil { - return nil, fmt.Errorf("decision: evaluation journal is not initialized") - } - if limit < 0 { - return nil, fmt.Errorf("decision: evaluation record limit cannot be negative") - } - journal.mu.Lock() - defer journal.mu.Unlock() - start := 0 - if limit > 0 && len(journal.records) > limit { - start = len(journal.records) - limit - } - return append([]EvaluationRecord(nil), journal.records[start:]...), nil -} - -func (journal *EvaluationJournal) EvaluationRecordForIntent(ctx context.Context, tenantID, intentHash string) (EvaluationRecord, bool, error) { - if err := ctx.Err(); err != nil { - return EvaluationRecord{}, false, err - } - if journal == nil { - return EvaluationRecord{}, false, fmt.Errorf("decision: evaluation journal is not initialized") - } - journal.mu.Lock() - defer journal.mu.Unlock() - for index := len(journal.records) - 1; index >= 0; index-- { - record := journal.records[index] - if record.TenantID == tenantID && record.IntentHash == intentHash { - return record, true, nil - } - } - return EvaluationRecord{}, false, nil -} - -func (journal *EvaluationJournal) load() error { - file, err := os.Open(journal.path) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return err - } - defer file.Close() - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64<<10), 1<<20) - line := 0 - for scanner.Scan() { - line++ - decoder := json.NewDecoder(bytes.NewReader(scanner.Bytes())) - decoder.DisallowUnknownFields() - var record EvaluationRecord - if err := decoder.Decode(&record); err != nil { - return fmt.Errorf("decision: decode evaluation journal line %d: %w", line, err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return fmt.Errorf("decision: evaluation journal line %d has trailing JSON", line) - } - if err := record.Validate(); err != nil { - return fmt.Errorf("decision: validate evaluation journal line %d: %w", line, err) - } - if existing, exists := journal.seen[record.ID]; exists { - if !evaluationRecordsEqual(existing, record) { - return fmt.Errorf("decision: conflicting evaluation usage unit %q", record.ID) - } - continue - } - journal.seen[record.ID] = record - journal.records = append(journal.records, record) - } - return scanner.Err() -} - -func evaluationRecordsEqual(first, second EvaluationRecord) bool { - firstJSON, _ := json.Marshal(first) - secondJSON, _ := json.Marshal(second) - return bytes.Equal(firstJSON, secondJSON) -} - -var _ EvaluationObserver = (*EvaluationJournal)(nil) -var _ EvaluationJournalStore = (*EvaluationJournal)(nil) -var _ EvaluationLookupStore = (*EvaluationJournal)(nil) diff --git a/decision/evaluator.go b/decision/evaluator.go deleted file mode 100644 index 44b181e..0000000 --- a/decision/evaluator.go +++ /dev/null @@ -1,576 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "strings" - "time" -) - -type EvaluationMode string - -const ( - EvaluationShadow EvaluationMode = "shadow" - EvaluationDenyOnly EvaluationMode = "deny_only" - EvaluationNarrow EvaluationMode = "narrow" -) - -// EvaluationFailureMode determines how a semantic evaluator outage is handled -// for one risk class. Inherit keeps the GuardedAuthorizer's legacy global -// FailClosed setting; explicit modes let tenants preserve availability for -// low-risk actions while failing closed for sensitive or irreversible work. -type EvaluationFailureMode string - -const ( - EvaluationFailureInherit EvaluationFailureMode = "" - EvaluationFailureOpen EvaluationFailureMode = "fail_open" - EvaluationFailureClosed EvaluationFailureMode = "fail_closed" -) - -// EvaluationRiskPolicy overrides semantic timeout and/or outage behavior for -// a signed Intent risk class. A zero timeout inherits GuardedAuthorizer.Timeout. -type EvaluationRiskPolicy struct { - Timeout time.Duration - FailureMode EvaluationFailureMode -} - -type EvaluatorIdentity struct { - EvaluatorID string `json:"evaluator_id"` - Model string `json:"model"` - ModelVersion string `json:"model_version"` - PromptVersion string `json:"prompt_version"` -} - -type EvaluationRecord struct { - Version uint16 `json:"version"` - ID string `json:"id"` - IntentHash string `json:"intent_hash"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Mode EvaluationMode `json:"mode"` - Identity EvaluatorIdentity `json:"identity"` - BaseOutcome Outcome `json:"base_outcome"` - SemanticOutcome Outcome `json:"semantic_outcome,omitempty"` - AppliedOutcome Outcome `json:"applied_outcome"` - Applied bool `json:"applied"` - StartedAt int64 `json:"started_at_unix_nano"` - CompletedAt int64 `json:"completed_at_unix_nano"` - TimeoutMillis int64 `json:"timeout_millis,omitempty"` - FailureMode EvaluationFailureMode `json:"failure_mode,omitempty"` - SemanticContextHash string `json:"semantic_context_hash,omitempty"` - MatchedClauseIDs []string `json:"matched_clause_ids,omitempty"` - ApprovalPlanID string `json:"approval_plan_id,omitempty"` - ApprovalPlanRevision uint64 `json:"approval_plan_revision,omitempty"` - ErrorCode string `json:"error_code,omitempty"` - ModelCalls uint64 `json:"model_calls,omitempty"` - InputTokens uint64 `json:"input_tokens,omitempty"` - OutputTokens uint64 `json:"output_tokens,omitempty"` -} - -func (record EvaluationRecord) Validate() error { - if record.Version != SchemaVersion || !lowerHex(record.ID, 64) || !lowerHex(record.IntentHash, 64) { - return fmt.Errorf("decision: invalid evaluation record identity") - } - for name, value := range map[string]string{ - "tenant_id": record.TenantID, "agent_id": record.AgentID, - "evaluator_id": record.Identity.EvaluatorID, "model": record.Identity.Model, - "model_version": record.Identity.ModelVersion, "prompt_version": record.Identity.PromptVersion, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - switch record.Mode { - case EvaluationShadow, EvaluationDenyOnly, EvaluationNarrow: - default: - return fmt.Errorf("decision: invalid evaluation mode %q", record.Mode) - } - if !validOutcome(record.BaseOutcome) || !validOutcome(record.AppliedOutcome) { - return fmt.Errorf("decision: invalid evaluation outcomes") - } - if record.SemanticOutcome != "" && !validOutcome(record.SemanticOutcome) { - return fmt.Errorf("decision: invalid semantic outcome") - } - if record.StartedAt <= 0 || record.CompletedAt < record.StartedAt { - return fmt.Errorf("decision: invalid evaluation timing") - } - if record.TimeoutMillis < 0 { - return fmt.Errorf("decision: invalid evaluation timeout") - } - if err := (ModelUsage{ModelCalls: record.ModelCalls, InputTokens: record.InputTokens, OutputTokens: record.OutputTokens}).Validate(); err != nil { - return err - } - switch record.FailureMode { - case EvaluationFailureInherit, EvaluationFailureOpen, EvaluationFailureClosed: - default: - return fmt.Errorf("decision: invalid evaluation failure mode %q", record.FailureMode) - } - if record.ErrorCode != "" { - if err := validateIdentifier("evaluation error_code", record.ErrorCode); err != nil { - return err - } - } - if record.SemanticContextHash != "" && !lowerHex(record.SemanticContextHash, 64) { - return fmt.Errorf("decision: invalid semantic context hash") - } - if len(record.MatchedClauseIDs) > 64 { - return fmt.Errorf("decision: too many matched semantic clauses") - } - seenClauses := make(map[string]struct{}, len(record.MatchedClauseIDs)) - for _, clauseID := range record.MatchedClauseIDs { - if err := validateIdentifier("matched semantic clause", clauseID); err != nil { - return err - } - if _, duplicate := seenClauses[clauseID]; duplicate { - return fmt.Errorf("decision: duplicate matched semantic clause") - } - seenClauses[clauseID] = struct{}{} - } - if (record.ApprovalPlanID == "") != (record.ApprovalPlanRevision == 0) { - return fmt.Errorf("decision: incomplete evaluation approval plan") - } - if record.ApprovalPlanID != "" { - if err := validateIdentifier("evaluation approval plan", record.ApprovalPlanID); err != nil { - return err - } - if record.SemanticOutcome != ApprovalRequired || len(record.MatchedClauseIDs) == 0 { - return fmt.Errorf("decision: evaluation approval plan lacks matching approval clause") - } - } - return nil -} - -func (record EvaluationRecord) UsageUnitID() string { return record.ID } - -type EvaluationObserver interface { - RecordEvaluation(context.Context, EvaluationRecord) error -} - -type GuardedAuthorizer struct { - Base Authorizer - Semantic Authorizer - Mode EvaluationMode - Identity EvaluatorIdentity - Observer EvaluationObserver - Timeout time.Duration - FailClosed bool - RiskPolicies map[RiskClass]EvaluationRiskPolicy - ContextProvider SemanticPolicyContextProvider - RequireObservation bool - Now func() time.Time -} - -// Authorize evaluates deterministic policy first. Semantic output is never -// permitted to expand the base result; its strongest production mode can only -// deny, require approval, or add constraints. -func (authorizer *GuardedAuthorizer) Authorize(ctx context.Context, intent Intent) (Decision, error) { - return authorizer.authorize(ctx, intent, nil, nil) -} - -// AuthorizeDisclosure preserves the same deterministic-first and -// non-expansion guarantees while passing only hash-bound typed metadata to -// evaluators that explicitly support it. A missing implementation is an error -// rather than a fallback to Authorize, because a required disclosure profile -// must not silently drop labels or residency information. -func (authorizer *GuardedAuthorizer) AuthorizeDisclosure(ctx context.Context, intent Intent, disclosure DisclosureBinding) (Decision, error) { - if err := disclosure.VerifyIntent(intent); err != nil { - return Decision{}, err - } - return authorizer.authorize(ctx, intent, &disclosure, nil) -} - -// AuthorizeFederatedContent evaluates the exact exchange body through Pilot's -// hosted semantic provider. The deterministic base still receives only the -// signed intent and disclosure metadata, preserving the rule that semantic -// content analysis can narrow but never create authority. -func (authorizer *GuardedAuthorizer) AuthorizeFederatedContent(ctx context.Context, intent Intent, content FederatedContent) (Decision, error) { - if err := content.VerifyIntent(intent); err != nil { - return Decision{}, err - } - cloned := content.Clone() - disclosure := cloned.Disclosure - return authorizer.authorize(ctx, intent, &disclosure, &cloned) -} - -func (authorizer *GuardedAuthorizer) authorize(ctx context.Context, intent Intent, disclosure *DisclosureBinding, content *FederatedContent) (Decision, error) { - if authorizer == nil || authorizer.Base == nil || authorizer.Semantic == nil || authorizer.Observer == nil { - return Decision{}, fmt.Errorf("decision: guarded authorizer is incomplete") - } - if err := authorizer.validateConfig(); err != nil { - return Decision{}, err - } - base, err := authorizeWithDisclosure(ctx, authorizer.Base, intent, disclosure) - if err != nil { - return Decision{}, fmt.Errorf("decision: deterministic evaluator failed: %w", err) - } - if err := validateTemplate(base); err != nil { - return Decision{}, fmt.Errorf("decision: invalid deterministic result: %w", err) - } - // Semantic evaluation is narrowing-only. Once deterministic policy has - // denied an action, forwarding its content to a model cannot change the - // answer and only adds cost, latency, and an unnecessary disclosure path. - if base.Outcome == Deny { - return base, nil - } - var semanticContext *SemanticPolicyContext - if authorizer.ContextProvider != nil { - policyContext, found, contextErr := authorizer.ContextProvider.SemanticPolicyContext(ctx, intent) - if contextErr != nil { - return Decision{}, fmt.Errorf("decision: load semantic policy context: %w", contextErr) - } - // A hosted model may interpret only clauses that an operator reviewed - // and activated. Federated content is still verified and retained by - // the exchange boundary, but sending it to a model without applicable - // policy would add disclosure, latency, and billable tokens without a - // governed question to answer. - if !found { - return base, nil - } - if found { - if err := policyContext.ValidateIntent(intent, intent.IssuedAt); err != nil { - return Decision{}, err - } - semanticContext = &policyContext - } - } - intentHash, err := intent.Hash() - if err != nil { - return Decision{}, err - } - now := time.Now - if authorizer.Now != nil { - now = authorizer.Now - } - started := now() - timeout, failClosed, failureMode := authorizer.evaluationPolicy(intent.Risk) - evaluationCtx := ctx - cancel := func() {} - if timeout > 0 { - evaluationCtx, cancel = context.WithTimeout(ctx, timeout) - } - evaluationCtx, usageRecorder := WithModelUsageRecorder(evaluationCtx) - semantic, semanticErr := authorizeSemanticWithDisclosure(evaluationCtx, authorizer.Semantic, intent, disclosure, content, semanticContext) - cancel() - completed := now() - modelUsage := usageRecorder.Snapshot() - if semanticErr == nil { - semanticErr = validateTemplate(semantic) - } - var matchedClauseIDs []string - var approvalPlanID string - var approvalPlanRevision uint64 - if semanticErr == nil && semanticContext != nil { - matchedClauseIDs, approvalPlanID, approvalPlanRevision, semanticErr = validateSemanticPolicyResult(semantic, *semanticContext) - if semanticErr == nil && approvalPlanID != "" { - semantic.Reasons = append(semantic.Reasons, fmt.Sprintf("approval-plan:%s:%d", approvalPlanID, approvalPlanRevision)) - } - } - applied := cloneDecisionTemplate(base) - appliedSemantic := false - if semanticErr == nil { - applied, appliedSemantic = applySemantic(base, semantic, authorizer.Mode) - } - record := EvaluationRecord{ - Version: SchemaVersion, ID: evaluationUsageID(intentHash, authorizer.Identity), - IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, - Mode: authorizer.Mode, Identity: authorizer.Identity, BaseOutcome: base.Outcome, - AppliedOutcome: applied.Outcome, Applied: appliedSemantic, - StartedAt: started.UnixNano(), CompletedAt: completed.UnixNano(), - TimeoutMillis: timeout.Milliseconds(), FailureMode: failureMode, - ModelCalls: modelUsage.ModelCalls, InputTokens: modelUsage.InputTokens, OutputTokens: modelUsage.OutputTokens, - } - if semanticContext != nil { - record.SemanticContextHash = semanticContext.ContextHash - } - record.MatchedClauseIDs = matchedClauseIDs - record.ApprovalPlanID, record.ApprovalPlanRevision = approvalPlanID, approvalPlanRevision - if semanticErr == nil { - record.SemanticOutcome = semantic.Outcome - } else { - record.ErrorCode = "evaluator_unavailable" - } - if err := record.Validate(); err != nil { - return Decision{}, err - } - observationErr := authorizer.Observer.RecordEvaluation(ctx, record) - if observationErr != nil && authorizer.RequireObservation { - return Decision{}, fmt.Errorf("decision: persist semantic evaluation: %w", observationErr) - } - if semanticErr != nil && failClosed { - return Decision{}, fmt.Errorf("decision: semantic evaluator failed closed: %w", semanticErr) - } - applied.Reasons = withEvaluationReason(applied.Reasons, record.ID) - return applied, nil -} - -func authorizeWithDisclosure(ctx context.Context, evaluator Authorizer, intent Intent, disclosure *DisclosureBinding) (Decision, error) { - if disclosure == nil { - return evaluator.Authorize(ctx, intent) - } - aware, supported := evaluator.(DisclosureAuthorizer) - if !supported { - return Decision{}, fmt.Errorf("decision: evaluator does not support disclosure binding") - } - return aware.AuthorizeDisclosure(ctx, intent, *disclosure) -} - -func authorizeSemanticWithDisclosure(ctx context.Context, evaluator Authorizer, intent Intent, disclosure *DisclosureBinding, content *FederatedContent, policy *SemanticPolicyContext) (Decision, error) { - if content != nil { - if disclosure == nil { - return Decision{}, fmt.Errorf("decision: federated content is missing disclosure binding") - } - if err := content.VerifyIntent(intent); err != nil { - return Decision{}, err - } - if policy == nil { - aware, supported := evaluator.(FederatedContentAuthorizer) - if !supported { - return Decision{}, fmt.Errorf("decision: evaluator does not support federated content") - } - return aware.AuthorizeFederatedContent(ctx, intent, content.Clone()) - } - aware, supported := evaluator.(SemanticContextFederatedContentAuthorizer) - if !supported { - return Decision{}, fmt.Errorf("decision: evaluator does not support semantic policy context with federated content") - } - return aware.AuthorizeSemanticFederatedContent(ctx, intent, content.Clone(), *policy) - } - if policy == nil { - return authorizeWithDisclosure(ctx, evaluator, intent, disclosure) - } - if disclosure == nil { - aware, supported := evaluator.(SemanticContextAuthorizer) - if !supported { - return Decision{}, fmt.Errorf("decision: evaluator does not support semantic policy context") - } - return aware.AuthorizeSemantic(ctx, intent, *policy) - } - aware, supported := evaluator.(SemanticContextDisclosureAuthorizer) - if !supported { - return Decision{}, fmt.Errorf("decision: evaluator does not support semantic policy context with disclosure") - } - return aware.AuthorizeSemanticDisclosure(ctx, intent, *disclosure, *policy) -} - -func validateSemanticPolicyResult(result Decision, policy SemanticPolicyContext) ([]string, string, uint64, error) { - if result.Outcome == Allow { - return nil, "", 0, nil - } - clauses := make(map[string]SemanticPolicyClause, len(policy.Clauses)) - for _, clause := range policy.Clauses { - clauses[clause.ID] = clause - } - matched := make([]string, 0) - seen := make(map[string]struct{}) - for _, reason := range result.Reasons { - if !strings.HasPrefix(reason, "semantic-clause:") { - continue - } - id := strings.TrimPrefix(reason, "semantic-clause:") - clause, found := clauses[id] - if !found { - return nil, "", 0, fmt.Errorf("decision: evaluator referenced inactive semantic clause %q", id) - } - if clause.OutcomeOnMatch != result.Outcome { - return nil, "", 0, fmt.Errorf("decision: evaluator outcome exceeds semantic clause %q", id) - } - if _, duplicate := seen[id]; !duplicate { - matched = append(matched, id) - seen[id] = struct{}{} - } - } - if len(matched) == 0 { - return nil, "", 0, fmt.Errorf("decision: narrowing semantic result lacks a reviewed clause") - } - if result.Outcome != ApprovalRequired { - return matched, "", 0, nil - } - var planID string - var planRevision uint64 - for _, id := range matched { - clause := clauses[id] - if planID == "" { - planID, planRevision = clause.ApprovalPlanID, clause.ApprovalPlanRevision - continue - } - if planID != clause.ApprovalPlanID || planRevision != clause.ApprovalPlanRevision { - return nil, "", 0, fmt.Errorf("decision: matched semantic clauses disagree on approval plan") - } - } - return matched, planID, planRevision, nil -} - -func (authorizer *GuardedAuthorizer) evaluationPolicy(risk RiskClass) (time.Duration, bool, EvaluationFailureMode) { - timeout, failClosed := authorizer.Timeout, authorizer.FailClosed - failureMode := EvaluationFailureOpen - if failClosed { - failureMode = EvaluationFailureClosed - } - if policy, exists := authorizer.RiskPolicies[risk]; exists { - if policy.Timeout > 0 { - timeout = policy.Timeout - } - if policy.FailureMode != EvaluationFailureInherit { - failureMode = policy.FailureMode - failClosed = policy.FailureMode == EvaluationFailureClosed - } - } - return timeout, failClosed, failureMode -} - -func (authorizer *GuardedAuthorizer) validateConfig() error { - switch authorizer.Mode { - case EvaluationShadow, EvaluationDenyOnly, EvaluationNarrow: - default: - return fmt.Errorf("decision: invalid semantic evaluation mode %q", authorizer.Mode) - } - if authorizer.Mode != EvaluationShadow && !authorizer.RequireObservation { - return fmt.Errorf("decision: autonomous semantic modes require durable observation") - } - for name, value := range map[string]string{ - "evaluator_id": authorizer.Identity.EvaluatorID, "model": authorizer.Identity.Model, - "model_version": authorizer.Identity.ModelVersion, "prompt_version": authorizer.Identity.PromptVersion, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - for risk, policy := range authorizer.RiskPolicies { - switch risk { - case RiskLow, RiskMedium, RiskHigh, RiskCritical: - default: - return fmt.Errorf("decision: invalid semantic risk policy %q", risk) - } - if policy.Timeout < 0 { - return fmt.Errorf("decision: semantic timeout for %s must not be negative", risk) - } - switch policy.FailureMode { - case EvaluationFailureInherit, EvaluationFailureOpen, EvaluationFailureClosed: - default: - return fmt.Errorf("decision: invalid semantic failure mode %q for %s", policy.FailureMode, risk) - } - } - return nil -} - -func applySemantic(base, semantic Decision, mode EvaluationMode) (Decision, bool) { - result := cloneDecisionTemplate(base) - if mode == EvaluationShadow || semantic.Outcome == Allow { - return result, false - } - if mode == EvaluationDenyOnly { - if semantic.Outcome == Deny && base.Outcome != Deny { - result.Outcome, result.Constraints = Deny, nil - result.Reasons = mergedReasons(base.Reasons, semantic.Reasons) - return result, true - } - return result, false - } - switch semantic.Outcome { - case Deny: - if base.Outcome != Deny { - result.Outcome, result.Constraints = Deny, nil - result.Reasons = mergedReasons(base.Reasons, semantic.Reasons) - return result, true - } - case ApprovalRequired: - if base.Outcome == Allow || base.Outcome == Constrain { - result.Outcome, result.Constraints = ApprovalRequired, nil - result.Reasons = mergedReasons(base.Reasons, semantic.Reasons) - return result, true - } - case Constrain: - if base.Outcome == Allow { - result.Outcome = Constrain - result.Constraints = append([]Constraint(nil), semantic.Constraints...) - result.Reasons = mergedReasons(base.Reasons, semantic.Reasons) - return result, true - } - if base.Outcome == Constrain { - result.Constraints = mergeConstraints(base.Constraints, semantic.Constraints) - result.Reasons = mergedReasons(base.Reasons, semantic.Reasons) - return result, len(result.Constraints) > len(base.Constraints) - } - } - return result, false -} - -func validateTemplate(template Decision) error { - probe := cloneDecisionTemplate(template) - probe.Version = SchemaVersion - probe.ID = "evaluation-template" - probe.IntentHash = strings.Repeat("0", 64) - probe.TenantID = "evaluation-template" - probe.AgentID = "evaluation-template" - probe.ProviderID = "evaluation-template" - probe.IssuedAt = 1 - probe.ExpiresAt = 2 - probe.KeyID = "evaluation-template" - return probe.Validate() -} - -func cloneDecisionTemplate(template Decision) Decision { - clone := template - clone.Reasons = append([]string(nil), template.Reasons...) - clone.Constraints = append([]Constraint(nil), template.Constraints...) - return clone -} - -func mergeConstraints(first, second []Constraint) []Constraint { - merged := append([]Constraint(nil), first...) - for _, candidate := range second { - found := false - for _, existing := range merged { - if existing.Key == candidate.Key && existing.Operator == candidate.Operator { - found = true - break - } - } - if !found && len(merged) < 32 { - merged = append(merged, candidate) - } - } - return merged -} - -func mergedReasons(first, second []string) []string { - merged := append([]string(nil), first...) - for _, reason := range second { - if len(merged) >= 15 { - break - } - merged = append(merged, reason) - } - return merged -} - -func withEvaluationReason(reasons []string, evaluationID string) []string { - if len(reasons) >= 16 { - reasons = append([]string(nil), reasons[:15]...) - } else { - reasons = append([]string(nil), reasons...) - } - return append(reasons, "evaluation:"+evaluationID) -} - -func evaluationUsageID(intentHash string, identity EvaluatorIdentity) string { - hash := sha256.New() - for _, value := range []string{"pilot-evaluation-unit-v1", intentHash, identity.EvaluatorID, identity.Model, identity.ModelVersion, identity.PromptVersion} { - var length [4]byte - length[0] = byte(len(value) >> 24) - length[1] = byte(len(value) >> 16) - length[2] = byte(len(value) >> 8) - length[3] = byte(len(value)) - hash.Write(length[:]) - hash.Write([]byte(value)) - } - return hex.EncodeToString(hash.Sum(nil)) -} - -func validOutcome(outcome Outcome) bool { - return outcome == Allow || outcome == Deny || outcome == Constrain || outcome == ApprovalRequired -} diff --git a/decision/evaluator_attestation.go b/decision/evaluator_attestation.go deleted file mode 100644 index efc3e26..0000000 --- a/decision/evaluator_attestation.go +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "encoding/base64" - "fmt" - "net/url" - "strings" - "time" -) - -const ( - EvaluatorAttestationVersion uint16 = 1 - EvaluatorAttestationDomain = "pilot-evaluator-attestation-v1" - MaxEvaluatorAttestationTTL = 24 * time.Hour -) - -// EvaluatorAttestation is a short-lived, independently signed assertion that -// an evaluator endpoint is approved for one residency. EvidenceHash binds an -// external attestation record without embedding operator or payload data in -// the control request. -type EvaluatorAttestation struct { - Version uint16 `json:"version"` - Endpoint string `json:"endpoint"` - Residency string `json:"residency"` - AttestorID string `json:"attestor_id"` - EvidenceHash string `json:"evidence_hash"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (attestation EvaluatorAttestation) Validate() error { - if attestation.Version != EvaluatorAttestationVersion { - return fmt.Errorf("decision: evaluator attestation version %d is unsupported", attestation.Version) - } - if _, err := canonicalEvaluatorEndpoint(attestation.Endpoint); err != nil { - return err - } - if !validDisclosureResidency(attestation.Residency) { - return fmt.Errorf("decision: invalid evaluator attestation residency %q", attestation.Residency) - } - for name, value := range map[string]string{"attestor_id": attestation.AttestorID, "key_id": attestation.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if !lowerHex(attestation.EvidenceHash, 64) { - return fmt.Errorf("decision: evaluator attestation evidence_hash must be 64 lowercase hex characters") - } - if err := validateWindow("evaluator attestation", attestation.IssuedAt, attestation.ExpiresAt, MaxEvaluatorAttestationTTL); err != nil { - return err - } - return nil -} - -func (attestation EvaluatorAttestation) Canonical() ([]byte, error) { - if err := attestation.Validate(); err != nil { - return nil, err - } - endpoint, err := canonicalEvaluatorEndpoint(attestation.Endpoint) - if err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(EvaluatorAttestationDomain) - writer.u16(attestation.Version) - writer.string(endpoint) - writer.string(attestation.Residency) - writer.string(attestation.AttestorID) - writer.string(attestation.EvidenceHash) - writer.i64(attestation.IssuedAt) - writer.i64(attestation.ExpiresAt) - writer.string(attestation.KeyID) - return writer.Bytes(), nil -} - -func (attestation *EvaluatorAttestation) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: evaluator attestation signing key is invalid") - } - canonical, err := attestation.Canonical() - if err != nil { - return err - } - attestation.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -// VerifyForEndpoint proves that a separately pinned attestor approved exactly -// this evaluator origin and residency at the current time. -func (attestation EvaluatorAttestation) VerifyForEndpoint(endpoint, residency, attestorID, keyID string, publicKey ed25519.PublicKey, now time.Time) error { - if err := attestation.Validate(); err != nil { - return err - } - expectedEndpoint, err := canonicalEvaluatorEndpoint(endpoint) - if err != nil { - return err - } - actualEndpoint, _ := canonicalEvaluatorEndpoint(attestation.Endpoint) - if actualEndpoint != expectedEndpoint || attestation.Residency != residency || attestation.AttestorID != attestorID || attestation.KeyID != keyID { - return fmt.Errorf("decision: evaluator attestation binding mismatch") - } - if len(publicKey) != ed25519.PublicKeySize { - return fmt.Errorf("decision: evaluator attestation public key is invalid") - } - if now.Unix() < attestation.IssuedAt-int64(MaxClockSkew/time.Second) || now.Unix() > attestation.ExpiresAt+int64(MaxClockSkew/time.Second) { - return fmt.Errorf("decision: evaluator attestation is outside its validity window") - } - canonical, err := attestation.Canonical() - if err != nil { - return err - } - signature, err := base64.StdEncoding.DecodeString(attestation.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("decision: evaluator attestation signature is invalid") - } - return nil -} - -func canonicalEvaluatorEndpoint(value string) (string, error) { - parsed, err := url.Parse(value) - if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", fmt.Errorf("decision: evaluator attestation endpoint is invalid") - } - if parsed.Scheme != "https" && parsed.Scheme != "http" { - return "", fmt.Errorf("decision: evaluator attestation endpoint is invalid") - } - parsed.Path = "" - parsed.RawPath = "" - return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), nil -} diff --git a/decision/evaluator_attestation_test.go b/decision/evaluator_attestation_test.go deleted file mode 100644 index 57bfb6a..0000000 --- a/decision/evaluator_attestation_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "crypto/rand" - "strings" - "testing" - "time" -) - -func TestEvaluatorAttestationBindsEndpointResidencyAndIndependentKey(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0).UTC() - attestation := EvaluatorAttestation{ - Version: EvaluatorAttestationVersion, Endpoint: "https://evaluator.example/v1/authorize", Residency: "eu-west-1", - AttestorID: "regional-attestor", EvidenceHash: strings.Repeat("a", 64), IssuedAt: now.Unix(), ExpiresAt: now.Add(10 * time.Minute).Unix(), KeyID: "region-key-1", - } - if err := attestation.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := attestation.VerifyForEndpoint("https://evaluator.example/v1/authorize", "eu-west-1", "regional-attestor", "region-key-1", publicKey, now); err != nil { - t.Fatal(err) - } - if err := attestation.VerifyForEndpoint("https://other.example", "eu-west-1", "regional-attestor", "region-key-1", publicKey, now); err == nil || !strings.Contains(err.Error(), "binding") { - t.Fatalf("endpoint mismatch error=%v", err) - } - if err := attestation.VerifyForEndpoint("https://evaluator.example", "us-east-1", "regional-attestor", "region-key-1", publicKey, now); err == nil || !strings.Contains(err.Error(), "binding") { - t.Fatalf("residency mismatch error=%v", err) - } - attestation.Signature = "bad" - if err := attestation.VerifyForEndpoint("https://evaluator.example", "eu-west-1", "regional-attestor", "region-key-1", publicKey, now); err == nil || !strings.Contains(err.Error(), "signature") { - t.Fatalf("signature mismatch error=%v", err) - } -} diff --git a/decision/evaluator_test.go b/decision/evaluator_test.go deleted file mode 100644 index 12d7705..0000000 --- a/decision/evaluator_test.go +++ /dev/null @@ -1,462 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "errors" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -type evaluationCollector struct { - mu sync.Mutex - records []EvaluationRecord - err error -} - -type disclosureEvaluatorFunc func(context.Context, Intent, DisclosureBinding) (Decision, error) - -type semanticEvaluatorFunc func(context.Context, Intent, SemanticPolicyContext) (Decision, error) - -type federatedEvaluatorFunc func(context.Context, Intent, FederatedContent) (Decision, error) - -func (evaluator federatedEvaluatorFunc) Authorize(context.Context, Intent) (Decision, error) { - return Decision{}, errors.New("plain semantic authorization is not expected") -} - -func (evaluator federatedEvaluatorFunc) AuthorizeFederatedContent(ctx context.Context, intent Intent, content FederatedContent) (Decision, error) { - return evaluator(ctx, intent, content) -} - -func (evaluator semanticEvaluatorFunc) Authorize(context.Context, Intent) (Decision, error) { - return Decision{}, errors.New("plain semantic authorization is not expected") -} - -func (evaluator semanticEvaluatorFunc) AuthorizeSemantic(ctx context.Context, intent Intent, policy SemanticPolicyContext) (Decision, error) { - return evaluator(ctx, intent, policy) -} - -type semanticContextProviderFunc func(context.Context, Intent) (SemanticPolicyContext, bool, error) - -func (provider semanticContextProviderFunc) SemanticPolicyContext(ctx context.Context, intent Intent) (SemanticPolicyContext, bool, error) { - return provider(ctx, intent) -} - -func (evaluator disclosureEvaluatorFunc) Authorize(context.Context, Intent) (Decision, error) { - return Decision{}, errors.New("plain authorization is not expected") -} - -func (evaluator disclosureEvaluatorFunc) AuthorizeDisclosure(ctx context.Context, intent Intent, disclosure DisclosureBinding) (Decision, error) { - return evaluator(ctx, intent, disclosure) -} - -func (collector *evaluationCollector) RecordEvaluation(_ context.Context, record EvaluationRecord) error { - collector.mu.Lock() - defer collector.mu.Unlock() - collector.records = append(collector.records, record) - return collector.err -} - -func evaluatorIdentity() EvaluatorIdentity { - return EvaluatorIdentity{EvaluatorID: "semantic-1", Model: "model-a", ModelVersion: "2026-07", PromptVersion: "prompt-3"} -} - -func TestGuardedAuthorizerShadowCannotChangeAuthority(t *testing.T) { - t.Parallel() - now := time.Unix(1785500000, 0) - collector := &evaluationCollector{} - authorizer := GuardedAuthorizer{ - Base: authorizerFunc(func(context.Context, Intent) (Decision, error) { - return Decision{Outcome: Allow, PolicyRevision: 7, RevocationEpoch: 2, Reasons: []string{"base"}}, nil - }), - Semantic: authorizerFunc(func(context.Context, Intent) (Decision, error) { - return Decision{Outcome: Deny, Reasons: []string{"semantic-risk"}}, nil - }), - Mode: EvaluationShadow, Identity: evaluatorIdentity(), Observer: collector, - Now: func() time.Time { return now }, - } - result, err := authorizer.Authorize(context.Background(), testIntent(t, now)) - if err != nil { - t.Fatal(err) - } - if result.Outcome != Allow || len(collector.records) != 1 || collector.records[0].Applied { - t.Fatalf("shadow result=%+v records=%+v", result, collector.records) - } - if !strings.HasPrefix(result.Reasons[len(result.Reasons)-1], "evaluation:") { - t.Fatal("signed decision template is not bound to evaluation usage ID") - } -} - -func TestGuardedAuthorizerPassesReviewedSemanticContextAndJournalsHash(t *testing.T) { - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - policy, err := NewSemanticPolicyContext(intent.TenantID, []SemanticPolicyClause{{ - ID: "clause-a", StatementID: "statement-a", StatementRevision: 4, Instruction: "Deny when the resource belongs to Acme.", - Actions: []string{intent.Action}, OutcomeOnMatch: Deny, MetadataFields: []string{"action", "resource"}, FailureMode: EvaluationFailureClosed, - }}) - if err != nil { - t.Fatal(err) - } - collector := &evaluationCollector{} - semanticCalls := 0 - authorizer := GuardedAuthorizer{ - Base: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{Outcome: Allow}, nil }), - Semantic: semanticEvaluatorFunc(func(ctx context.Context, got Intent, semanticContext SemanticPolicyContext) (Decision, error) { - semanticCalls++ - if got.ID != intent.ID || semanticContext.ContextHash != policy.ContextHash || semanticContext.Clauses[0].StatementID != "statement-a" { - t.Fatalf("semantic context mismatch: intent=%+v context=%+v", got, semanticContext) - } - if err := ReportModelUsage(ctx, ModelUsage{ModelCalls: 1, InputTokens: 41, OutputTokens: 7}); err != nil { - t.Fatal(err) - } - return Decision{Outcome: Deny, Reasons: []string{"semantic-clause:clause-a"}}, nil - }), - ContextProvider: semanticContextProviderFunc(func(context.Context, Intent) (SemanticPolicyContext, bool, error) { return policy, true, nil }), - Mode: EvaluationNarrow, Identity: evaluatorIdentity(), Observer: collector, RequireObservation: true, Now: func() time.Time { return now }, - } - result, err := authorizer.Authorize(context.Background(), intent) - if err != nil { - t.Fatal(err) - } - if result.Outcome != Deny || semanticCalls != 1 || len(collector.records) != 1 || collector.records[0].SemanticContextHash != policy.ContextHash { - t.Fatalf("result=%+v calls=%d records=%+v", result, semanticCalls, collector.records) - } - if record := collector.records[0]; record.ModelCalls != 1 || record.InputTokens != 41 || record.OutputTokens != 7 { - t.Fatalf("model usage not journaled: %+v", record) - } -} - -func TestGuardedAuthorizerSkipsSemanticWhenNoActiveClause(t *testing.T) { - now := time.Unix(1785500000, 0) - semanticCalls := 0 - authorizer := GuardedAuthorizer{ - Base: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{Outcome: Allow}, nil }), - Semantic: semanticEvaluatorFunc(func(context.Context, Intent, SemanticPolicyContext) (Decision, error) { - semanticCalls++ - return Decision{Outcome: Deny}, nil - }), - ContextProvider: semanticContextProviderFunc(func(context.Context, Intent) (SemanticPolicyContext, bool, error) { - return SemanticPolicyContext{}, false, nil - }), - Mode: EvaluationNarrow, Identity: evaluatorIdentity(), Observer: &evaluationCollector{}, RequireObservation: true, Now: func() time.Time { return now }, - } - result, err := authorizer.Authorize(context.Background(), testIntent(t, now)) - if err != nil || result.Outcome != Allow || semanticCalls != 0 { - t.Fatalf("result=%+v calls=%d err=%v", result, semanticCalls, err) - } -} - -func TestGuardedAuthorizerDoesNotDiscloseHostedContentWithoutActiveClause(t *testing.T) { - now := time.Unix(1785500000, 0) - body := []byte("customer message body") - disclosure := DisclosureBinding{ - Version: DisclosureBindingRetentionVersion, ContentHash: HashPayload(body), DeclaredBytes: uint64(len(body)), - ContentType: "text/plain", Labels: []string{"customer-message"}, Recipient: "agent:support", Purpose: "reply to customer", Residency: "eu-west-1", RetentionClass: "standard", - } - content, err := NewFederatedContent(disclosure, body) - if err != nil { - t.Fatal(err) - } - intent := testIntent(t, now) - intent.Audience, intent.Purpose = disclosure.Recipient, disclosure.Purpose - intent.PayloadHash, err = disclosure.Hash() - if err != nil { - t.Fatal(err) - } - semanticCalls := 0 - collector := &evaluationCollector{} - authorizer := GuardedAuthorizer{ - Base: disclosureEvaluatorFunc(func(context.Context, Intent, DisclosureBinding) (Decision, error) { - return Decision{Outcome: Allow}, nil - }), - Semantic: federatedEvaluatorFunc(func(_ context.Context, got Intent, gotContent FederatedContent) (Decision, error) { - semanticCalls++ - if got.ID != intent.ID || string(gotContent.Body) != string(body) { - t.Fatalf("hosted content mismatch: intent=%+v content=%q", got, gotContent.Body) - } - return Decision{Outcome: Deny, Reasons: []string{"hosted_content_denied"}}, nil - }), - ContextProvider: semanticContextProviderFunc(func(context.Context, Intent) (SemanticPolicyContext, bool, error) { - return SemanticPolicyContext{}, false, nil - }), - Mode: EvaluationNarrow, Identity: evaluatorIdentity(), Observer: collector, RequireObservation: true, Now: func() time.Time { return now }, - } - result, err := authorizer.AuthorizeFederatedContent(context.Background(), intent, content) - if err != nil || result.Outcome != Allow || semanticCalls != 0 || len(collector.records) != 0 { - t.Fatalf("result=%+v calls=%d records=%+v err=%v", result, semanticCalls, collector.records, err) - } -} - -func TestGuardedAuthorizerDoesNotDiscloseContentAfterDeterministicDeny(t *testing.T) { - now := time.Unix(1785500000, 0) - body := []byte("destructive command with sensitive arguments") - disclosure := DisclosureBinding{ - Version: DisclosureBindingVersion, ContentHash: HashPayload(body), DeclaredBytes: uint64(len(body)), - ContentType: "text/plain", Labels: []string{"restricted"}, Recipient: "process:host", Purpose: "execute command", Residency: "eu", - } - content, err := NewFederatedContent(disclosure, body) - if err != nil { - t.Fatal(err) - } - intent := testIntent(t, now) - intent.Audience, intent.Purpose = disclosure.Recipient, disclosure.Purpose - intent.PayloadHash, err = disclosure.Hash() - if err != nil { - t.Fatal(err) - } - semanticCalls := 0 - collector := &evaluationCollector{} - authorizer := GuardedAuthorizer{ - Base: disclosureEvaluatorFunc(func(context.Context, Intent, DisclosureBinding) (Decision, error) { - return Decision{Outcome: Deny, Reasons: []string{"deterministic-deny"}}, nil - }), - Semantic: federatedEvaluatorFunc(func(context.Context, Intent, FederatedContent) (Decision, error) { - semanticCalls++ - return Decision{Outcome: Allow}, nil - }), - Mode: EvaluationNarrow, Identity: evaluatorIdentity(), Observer: collector, RequireObservation: true, Now: func() time.Time { return now }, - } - result, err := authorizer.AuthorizeFederatedContent(context.Background(), intent, content) - if err != nil || result.Outcome != Deny || semanticCalls != 0 || len(collector.records) != 0 { - t.Fatalf("result=%+v calls=%d records=%+v err=%v", result, semanticCalls, collector.records, err) - } -} - -func TestSemanticApprovalPinsReviewedPlanAndRejectsInventedClause(t *testing.T) { - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - policy, err := NewSemanticPolicyContext(intent.TenantID, []SemanticPolicyClause{{ - ID: "clause-approval", StatementID: "statement-a", StatementRevision: 7, Instruction: "Require security approval for an external recipient.", - Actions: []string{intent.Action}, OutcomeOnMatch: ApprovalRequired, ApprovalPlanID: "security-review", ApprovalPlanRevision: 3, - MetadataFields: []string{"resource"}, FailureMode: EvaluationFailureClosed, - }}) - if err != nil { - t.Fatal(err) - } - collector := &evaluationCollector{} - semantic := semanticEvaluatorFunc(func(context.Context, Intent, SemanticPolicyContext) (Decision, error) { - return Decision{Outcome: ApprovalRequired, Reasons: []string{"semantic-clause:clause-approval"}}, nil - }) - authorizer := GuardedAuthorizer{ - Base: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{Outcome: Allow}, nil }), Semantic: semantic, - ContextProvider: semanticContextProviderFunc(func(context.Context, Intent) (SemanticPolicyContext, bool, error) { return policy, true, nil }), - Mode: EvaluationNarrow, Identity: evaluatorIdentity(), Observer: collector, RequireObservation: true, FailClosed: true, Now: func() time.Time { return now }, - } - result, err := authorizer.Authorize(context.Background(), intent) - if err != nil { - t.Fatal(err) - } - if result.Outcome != ApprovalRequired || !containsString(result.Reasons, "approval-plan:security-review:3") { - t.Fatalf("approval plan was not pinned in result: %+v", result) - } - if len(collector.records) != 1 || collector.records[0].ApprovalPlanID != "security-review" || collector.records[0].ApprovalPlanRevision != 3 { - t.Fatalf("approval plan was not journaled: %+v", collector.records) - } - authorizer.Semantic = semanticEvaluatorFunc(func(context.Context, Intent, SemanticPolicyContext) (Decision, error) { - return Decision{Outcome: ApprovalRequired, Reasons: []string{"semantic-clause:invented"}}, nil - }) - if _, err := authorizer.Authorize(context.Background(), intent); err == nil || !strings.Contains(err.Error(), "inactive semantic clause") { - t.Fatalf("invented semantic clause was accepted: %v", err) - } -} - -func TestGuardedAuthorizerPassesDisclosureOnlyToAwareEvaluators(t *testing.T) { - now := time.Unix(1785500000, 0) - disclosure := DisclosureBinding{ - Version: DisclosureBindingVersion, ContentHash: HashPayload([]byte("invoice")), DeclaredBytes: 7, - ContentType: "application/pdf", Labels: []string{"finance", "pii"}, Recipient: "agent:finance", - Purpose: "invoice-payment", Residency: "eu-west-1", Filename: "invoice.pdf", - } - disclosureHash, err := disclosure.Hash() - if err != nil { - t.Fatal(err) - } - intent := testIntent(t, now) - intent.Audience, intent.Purpose, intent.PayloadHash = disclosure.Recipient, disclosure.Purpose, disclosureHash - var baseCalls, semanticCalls int - base := disclosureEvaluatorFunc(func(_ context.Context, received Intent, got DisclosureBinding) (Decision, error) { - baseCalls++ - if received.PayloadHash != disclosureHash || got.Residency != disclosure.Residency { - t.Fatalf("base metadata was not bound: intent=%+v disclosure=%+v", received, got) - } - return Decision{Outcome: Allow}, nil - }) - semantic := disclosureEvaluatorFunc(func(_ context.Context, _ Intent, got DisclosureBinding) (Decision, error) { - semanticCalls++ - if got.ContentType != disclosure.ContentType || got.Filename != disclosure.Filename { - t.Fatalf("semantic metadata mismatch: %+v", got) - } - return Decision{Outcome: Deny}, nil - }) - authorizer := GuardedAuthorizer{ - Base: base, Semantic: semantic, Mode: EvaluationDenyOnly, Identity: evaluatorIdentity(), - Observer: &evaluationCollector{}, RequireObservation: true, Now: func() time.Time { return now }, - } - result, err := authorizer.AuthorizeDisclosure(context.Background(), intent, disclosure) - if err != nil { - t.Fatal(err) - } - if result.Outcome != Deny || baseCalls != 1 || semanticCalls != 1 { - t.Fatalf("result=%+v base=%d semantic=%d", result, baseCalls, semanticCalls) - } - - authorizer.Base = authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{Outcome: Allow}, nil }) - if _, err := authorizer.AuthorizeDisclosure(context.Background(), intent, disclosure); err == nil || !strings.Contains(err.Error(), "does not support disclosure") { - t.Fatalf("non-disclosure base evaluator was accepted: %v", err) - } -} - -func TestGuardedAuthorizerDenyOnlyAndNarrowModes(t *testing.T) { - t.Parallel() - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - for _, test := range []struct { - name string - mode EvaluationMode - base Decision - semantic Decision - want Outcome - constraints int - }{ - {name: "deny-only applies deny", mode: EvaluationDenyOnly, base: Decision{Outcome: Allow}, semantic: Decision{Outcome: Deny}, want: Deny}, - {name: "deny-only ignores constraint", mode: EvaluationDenyOnly, base: Decision{Outcome: Allow}, semantic: Decision{Outcome: Constrain, Constraints: []Constraint{{Key: "amount", Operator: "max", Value: "10"}}}, want: Allow}, - {name: "narrow applies constraint", mode: EvaluationNarrow, base: Decision{Outcome: Allow}, semantic: Decision{Outcome: Constrain, Constraints: []Constraint{{Key: "amount", Operator: "max", Value: "10"}}}, want: Constrain, constraints: 1}, - {name: "allow cannot expand deny", mode: EvaluationNarrow, base: Decision{Outcome: Deny}, semantic: Decision{Outcome: Allow}, want: Deny}, - } { - test := test - t.Run(test.name, func(t *testing.T) { - collector := &evaluationCollector{} - authorizer := GuardedAuthorizer{ - Base: authorizerFunc(func(context.Context, Intent) (Decision, error) { return test.base, nil }), - Semantic: authorizerFunc(func(context.Context, Intent) (Decision, error) { return test.semantic, nil }), - Mode: test.mode, Identity: evaluatorIdentity(), Observer: collector, - RequireObservation: test.mode != EvaluationShadow, Now: func() time.Time { return now }, - } - result, err := authorizer.Authorize(context.Background(), intent) - if err != nil { - t.Fatal(err) - } - if result.Outcome != test.want || len(result.Constraints) != test.constraints { - t.Fatalf("result=%+v", result) - } - }) - } -} - -func TestGuardedAuthorizerFailureAndObservationPolicies(t *testing.T) { - t.Parallel() - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - collector := &evaluationCollector{} - authorizer := GuardedAuthorizer{ - Base: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{Outcome: Allow}, nil }), - Semantic: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{}, errors.New("model down") }), - Mode: EvaluationShadow, Identity: evaluatorIdentity(), Observer: collector, - FailClosed: true, Now: func() time.Time { return now }, - } - if _, err := authorizer.Authorize(context.Background(), intent); err == nil { - t.Fatal("fail-closed semantic outage was accepted") - } - if len(collector.records) != 1 || collector.records[0].ErrorCode == "" { - t.Fatalf("semantic failure was not observed: %+v", collector.records) - } - collector.err = errors.New("journal down") - authorizer.FailClosed = false - authorizer.RequireObservation = true - if _, err := authorizer.Authorize(context.Background(), intent); err == nil { - t.Fatal("required observation failure was accepted") - } -} - -func TestGuardedAuthorizerAppliesRiskSpecificFailurePolicyAndEvidence(t *testing.T) { - t.Parallel() - now := time.Unix(1785500000, 0) - collector := &evaluationCollector{} - authorizer := GuardedAuthorizer{ - Base: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{Outcome: Allow}, nil }), - Semantic: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{}, errors.New("model down") }), - Mode: EvaluationShadow, Identity: evaluatorIdentity(), Observer: collector, - FailClosed: false, Timeout: time.Second, - RiskPolicies: map[RiskClass]EvaluationRiskPolicy{ - RiskHigh: {Timeout: 20 * time.Millisecond, FailureMode: EvaluationFailureClosed}, - RiskLow: {FailureMode: EvaluationFailureOpen}, - }, - Now: func() time.Time { return now }, - } - high := testIntent(t, now) - high.Risk = RiskHigh - if _, err := authorizer.Authorize(context.Background(), high); err == nil { - t.Fatal("high-risk evaluator outage did not fail closed") - } - low := testIntent(t, now) - low.Risk = RiskLow - result, err := authorizer.Authorize(context.Background(), low) - if err != nil || result.Outcome != Allow { - t.Fatalf("low-risk evaluator outage result=%+v err=%v", result, err) - } - if len(collector.records) != 2 { - t.Fatalf("evaluation records=%+v", collector.records) - } - highRecord, lowRecord := collector.records[0], collector.records[1] - if highRecord.FailureMode != EvaluationFailureClosed || highRecord.TimeoutMillis != 20 || highRecord.ErrorCode == "" { - t.Fatalf("high-risk evaluation evidence=%+v", highRecord) - } - if lowRecord.FailureMode != EvaluationFailureOpen || lowRecord.TimeoutMillis != 1000 || lowRecord.ErrorCode == "" { - t.Fatalf("low-risk evaluation evidence=%+v", lowRecord) - } -} - -func TestAutonomousSemanticModeRequiresDurableObservation(t *testing.T) { - t.Parallel() - now := time.Unix(1785500000, 0) - authorizer := GuardedAuthorizer{ - Base: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{Outcome: Allow}, nil }), - Semantic: authorizerFunc(func(context.Context, Intent) (Decision, error) { return Decision{Outcome: Deny}, nil }), - Mode: EvaluationDenyOnly, Identity: evaluatorIdentity(), Observer: &evaluationCollector{}, - Now: func() time.Time { return now }, - } - if _, err := authorizer.Authorize(context.Background(), testIntent(t, now)); err == nil { - t.Fatal("autonomous semantic decision ran without durable-observation requirement") - } -} - -func TestEvaluationJournalDeduplicatesUsageAcrossRestart(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "evaluations.jsonl") - journal, err := OpenEvaluationJournal(path) - if err != nil { - t.Fatal(err) - } - record := EvaluationRecord{ - Version: SchemaVersion, ID: strings.Repeat("a", 64), IntentHash: strings.Repeat("b", 64), - TenantID: "tenant-a", AgentID: "agent-1", Mode: EvaluationShadow, - Identity: evaluatorIdentity(), BaseOutcome: Allow, SemanticOutcome: Deny, AppliedOutcome: Allow, - StartedAt: 1, CompletedAt: 2, - } - if err := journal.RecordEvaluation(context.Background(), record); err != nil { - t.Fatal(err) - } - if err := journal.RecordEvaluation(context.Background(), record); err != nil { - t.Fatal(err) - } - reopened, err := OpenEvaluationJournal(path) - if err != nil { - t.Fatal(err) - } - if err := reopened.RecordEvaluation(context.Background(), record); err != nil { - t.Fatal(err) - } - lookedUp, found, err := reopened.EvaluationRecordForIntent(context.Background(), record.TenantID, record.IntentHash) - if err != nil || !found || lookedUp.ID != record.ID { - t.Fatalf("intent lookup=%+v found=%v err=%v", lookedUp, found, err) - } - conflict := record - conflict.AppliedOutcome = Deny - if err := reopened.RecordEvaluation(context.Background(), conflict); err == nil { - t.Fatal("conflicting evaluation usage unit was accepted") - } -} diff --git a/decision/federated_content.go b/decision/federated_content.go deleted file mode 100644 index a8a633c..0000000 --- a/decision/federated_content.go +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" -) - -const FederatedContentVersion uint16 = 1 - -// FederatedContent is the exact exchange body submitted to Pilot's hosted -// federation boundary. Disclosure is signed into the Intent through -// Intent.PayloadHash; it in turn binds the body hash, byte length, media type, -// recipient, purpose, residency, filename, labels, and retention class. -// -// Body is deliberately absent from Decisions, approval transactions, -// evaluation journals, and action receipts. The hosted exchange repository -// encrypts it separately for the tenant-scoped operator experience. -type FederatedContent struct { - Version uint16 `json:"version"` - Disclosure DisclosureBinding `json:"disclosure"` - Body []byte `json:"body"` -} - -func NewFederatedContent(disclosure DisclosureBinding, body []byte) (FederatedContent, error) { - content := FederatedContent{ - Version: FederatedContentVersion, - Disclosure: DisclosureBinding{ - Version: disclosure.Version, ContentHash: disclosure.ContentHash, - DeclaredBytes: disclosure.DeclaredBytes, ContentType: disclosure.ContentType, - Labels: append([]string(nil), disclosure.Labels...), Recipient: disclosure.Recipient, - Purpose: disclosure.Purpose, Residency: disclosure.Residency, Filename: disclosure.Filename, - TransferID: disclosure.TransferID, RetentionClass: disclosure.RetentionClass, - }, - Body: append([]byte(nil), body...), - } - if err := content.Validate(); err != nil { - return FederatedContent{}, err - } - return content, nil -} - -func (content FederatedContent) Validate() error { - if content.Version != FederatedContentVersion { - return fmt.Errorf("decision: unsupported federated content version %d", content.Version) - } - if err := content.Disclosure.Validate(); err != nil { - return err - } - if uint64(len(content.Body)) != content.Disclosure.DeclaredBytes { - return fmt.Errorf("decision: federated content byte length does not match disclosure") - } - sum := sha256.Sum256(content.Body) - if hex.EncodeToString(sum[:]) != content.Disclosure.ContentHash { - return fmt.Errorf("decision: federated content hash does not match disclosure") - } - return nil -} - -func (content FederatedContent) VerifyIntent(intent Intent) error { - if err := content.Validate(); err != nil { - return err - } - return content.Disclosure.VerifyIntent(intent) -} - -func (content FederatedContent) Clone() FederatedContent { - cloned, _ := NewFederatedContent(content.Disclosure, content.Body) - return cloned -} diff --git a/decision/federation_result.go b/decision/federation_result.go deleted file mode 100644 index 5c5e8a3..0000000 --- a/decision/federation_result.go +++ /dev/null @@ -1,161 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "time" -) - -const ( - FederationResultVersion uint16 = 1 - FederationResultDomain = "pilot-federation-result-v1" -) - -type FederationResultStatus string - -const ( - FederationResultSucceeded FederationResultStatus = "succeeded" - FederationResultFailed FederationResultStatus = "failed" - FederationResultSkipped FederationResultStatus = "skipped" - FederationResultDenied FederationResultStatus = "denied" - FederationResultApprovalPending FederationResultStatus = "approval_pending" -) - -// FederationResult is the enrolled node's signed post-hook assertion for one -// hosted exchange. IntentHash and DecisionID bind the exact authorization that -// preceded execution. ResponseDisclosureHash, when present, binds the complete -// response content carried beside this object without placing plaintext in -// logs, receipts, or command telemetry. -type FederationResult struct { - Version uint16 `json:"version"` - ID string `json:"id"` - ExchangeID string `json:"exchange_id"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - IntentHash string `json:"intent_hash"` - DecisionID string `json:"decision_id"` - Status FederationResultStatus `json:"status"` - ErrorCode string `json:"error_code,omitempty"` - ResponseDisclosureHash string `json:"response_disclosure_hash,omitempty"` - ObservedAt int64 `json:"observed_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func NewFederationResult(exchangeID string, intent Intent, result Decision, status FederationResultStatus, errorCode string, response *DisclosureBinding, observedAt time.Time, keyID string) (FederationResult, error) { - intentHash, err := intent.Hash() - if err != nil { - return FederationResult{}, err - } - responseHash := "" - if response != nil { - responseHash, err = response.Hash() - if err != nil { - return FederationResult{}, err - } - } - record := FederationResult{ - Version: FederationResultVersion, ExchangeID: exchangeID, - TenantID: intent.TenantID, AgentID: intent.AgentID, IntentHash: intentHash, - DecisionID: result.ID, Status: status, ErrorCode: errorCode, - ResponseDisclosureHash: responseHash, ObservedAt: observedAt.UTC().Unix(), KeyID: keyID, - } - digest := sha256.Sum256([]byte(FederationResultDomain + "\x00" + exchangeID + "\x00" + intentHash + "\x00" + result.ID + "\x00" + string(status) + "\x00" + fmt.Sprint(record.ObservedAt) + "\x00" + responseHash)) - record.ID = "result-" + hex.EncodeToString(digest[:16]) - if err := record.Validate(); err != nil { - return FederationResult{}, err - } - return record, nil -} - -func (result FederationResult) Validate() error { - if result.Version != FederationResultVersion { - return fmt.Errorf("decision: unsupported federation result version") - } - for name, value := range map[string]string{ - "id": result.ID, "exchange_id": result.ExchangeID, "tenant_id": result.TenantID, - "agent_id": result.AgentID, "decision_id": result.DecisionID, "key_id": result.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if !lowerHex(result.IntentHash, 64) || result.ResponseDisclosureHash != "" && !lowerHex(result.ResponseDisclosureHash, 64) { - return fmt.Errorf("decision: invalid federation result hash binding") - } - switch result.Status { - case FederationResultSucceeded: - if result.ErrorCode != "" { - return fmt.Errorf("decision: successful federation result has an error code") - } - case FederationResultFailed: - if err := validateIdentifier("error_code", result.ErrorCode); err != nil { - return err - } - case FederationResultSkipped, FederationResultDenied, FederationResultApprovalPending: - if result.ErrorCode != "" { - if err := validateIdentifier("error_code", result.ErrorCode); err != nil { - return err - } - } - default: - return fmt.Errorf("decision: invalid federation result status %q", result.Status) - } - if result.ObservedAt <= 0 { - return fmt.Errorf("decision: invalid federation result observation time") - } - return nil -} - -func (result FederationResult) Canonical() ([]byte, error) { - if err := result.Validate(); err != nil { - return nil, err - } - w := canonicalWriter{} - w.string(FederationResultDomain) - w.u16(result.Version) - w.string(result.ID) - w.string(result.ExchangeID) - w.string(result.TenantID) - w.string(result.AgentID) - w.string(result.IntentHash) - w.string(result.DecisionID) - w.string(string(result.Status)) - w.string(result.ErrorCode) - w.string(result.ResponseDisclosureHash) - w.i64(result.ObservedAt) - w.string(result.KeyID) - return w.Bytes(), nil -} - -func (result *FederationResult) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid federation result signing key") - } - canonical, err := result.Canonical() - if err != nil { - return err - } - result.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (result FederationResult) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := result.Canonical() - if err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize || result.ObservedAt > now.Unix()+int64(MaxClockSkew/time.Second) || result.ObservedAt < now.Add(-24*time.Hour).Unix() { - return fmt.Errorf("decision: federation result is outside its accepted observation window") - } - signature, err := base64.StdEncoding.DecodeString(result.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("decision: invalid federation result signature") - } - return nil -} diff --git a/decision/mandate.go b/decision/mandate.go deleted file mode 100644 index 6ebb9b1..0000000 --- a/decision/mandate.go +++ /dev/null @@ -1,381 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "sort" - "strings" - "time" -) - -const ( - MandateDomain = "pilot-mandate-v1" - MaxMandateTTL = 90 * 24 * time.Hour - MaxMandateActions = 64 - MaxMandateResources = 64 - MaxMandateConstraints = 32 - MaxMandateApprovals = 32 -) - -// Mandate is a tenant-issuer-signed delegation to one workload. It is a -// ceiling, not a provider decision: a matching policy Decision may only be -// used when it is equal to or narrower than this delegation. -// -// Audience and Purpose are intentionally explicit. A caller cannot use a -// mandate issued for one counterparty or business purpose with another exact -// Intent, even where action and resource names happen to match. -type Mandate struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TenantID string `json:"tenant_id"` - SubjectAgentID string `json:"subject_agent_id"` - Actions []string `json:"actions"` - ResourcePrefixes []string `json:"resource_prefixes"` - Audience string `json:"audience"` - Purpose string `json:"purpose"` - Constraints []Constraint `json:"constraints,omitempty"` - RequiredApprovals uint16 `json:"required_approvals,omitempty"` - RevocationEpoch uint64 `json:"revocation_epoch"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (mandate Mandate) Validate() error { - if mandate.Version != SchemaVersion { - return fmt.Errorf("decision: mandate version %d is unsupported", mandate.Version) - } - for name, value := range map[string]string{ - "mandate id": mandate.ID, "tenant_id": mandate.TenantID, "subject_agent_id": mandate.SubjectAgentID, "key_id": mandate.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if len(mandate.Actions) == 0 || len(mandate.Actions) > MaxMandateActions { - return fmt.Errorf("decision: mandate must contain 1-%d actions", MaxMandateActions) - } - if len(mandate.ResourcePrefixes) == 0 || len(mandate.ResourcePrefixes) > MaxMandateResources { - return fmt.Errorf("decision: mandate must contain 1-%d resource prefixes", MaxMandateResources) - } - if mandate.Audience == "" || !validMandateAudience(mandate.Audience) { - return fmt.Errorf("decision: mandate audience is invalid") - } - if err := validateText("mandate purpose", mandate.Purpose, 256, false); err != nil { - return err - } - seenActions := make(map[string]struct{}, len(mandate.Actions)) - for _, action := range mandate.Actions { - if !validMandateAction(action) { - return fmt.Errorf("decision: invalid mandate action %q", action) - } - if _, exists := seenActions[action]; exists { - return fmt.Errorf("decision: duplicate mandate action %q", action) - } - seenActions[action] = struct{}{} - } - seenResources := make(map[string]struct{}, len(mandate.ResourcePrefixes)) - for _, resource := range mandate.ResourcePrefixes { - if !validMandateResource(resource) { - return fmt.Errorf("decision: invalid mandate resource prefix %q", resource) - } - if _, exists := seenResources[resource]; exists { - return fmt.Errorf("decision: duplicate mandate resource prefix %q", resource) - } - seenResources[resource] = struct{}{} - } - if len(mandate.Constraints) > MaxMandateConstraints { - return fmt.Errorf("decision: mandate has more than %d constraints", MaxMandateConstraints) - } - seenConstraints := make(map[string]struct{}, len(mandate.Constraints)) - for _, constraint := range mandate.Constraints { - if err := validateConstraint(constraint); err != nil { - return fmt.Errorf("decision: invalid mandate constraint: %w", err) - } - identity := constraint.Key + "\x00" + constraint.Operator - if _, exists := seenConstraints[identity]; exists { - return fmt.Errorf("decision: duplicate mandate constraint %q/%q", constraint.Key, constraint.Operator) - } - seenConstraints[identity] = struct{}{} - } - if mandate.RequiredApprovals > MaxMandateApprovals { - return fmt.Errorf("decision: mandate required approvals exceeds %d", MaxMandateApprovals) - } - if mandate.RevocationEpoch == 0 { - return fmt.Errorf("decision: mandate revocation epoch must be positive") - } - if mandate.IssuedAt <= 0 || mandate.ExpiresAt <= mandate.IssuedAt || mandate.ExpiresAt-mandate.IssuedAt > int64(MaxMandateTTL/time.Second) { - return fmt.Errorf("decision: mandate validity window is invalid") - } - return nil -} - -func (mandate Mandate) Canonical() ([]byte, error) { - if err := mandate.Validate(); err != nil { - return nil, err - } - actions := append([]string(nil), mandate.Actions...) - resources := append([]string(nil), mandate.ResourcePrefixes...) - constraints := append([]Constraint(nil), mandate.Constraints...) - sort.Strings(actions) - sort.Strings(resources) - sort.Slice(constraints, func(left, right int) bool { - if constraints[left].Key != constraints[right].Key { - return constraints[left].Key < constraints[right].Key - } - if constraints[left].Operator != constraints[right].Operator { - return constraints[left].Operator < constraints[right].Operator - } - return constraints[left].Value < constraints[right].Value - }) - writer := canonicalWriter{} - writer.string(MandateDomain) - writer.u16(mandate.Version) - writer.string(mandate.ID) - writer.string(mandate.TenantID) - writer.string(mandate.SubjectAgentID) - writer.u16(uint16(len(actions))) - for _, action := range actions { - writer.string(action) - } - writer.u16(uint16(len(resources))) - for _, resource := range resources { - writer.string(resource) - } - writer.string(mandate.Audience) - writer.string(mandate.Purpose) - writer.u16(uint16(len(constraints))) - for _, constraint := range constraints { - writer.string(constraint.Key) - writer.string(constraint.Operator) - writer.string(constraint.Value) - } - writer.u16(mandate.RequiredApprovals) - writer.u64(mandate.RevocationEpoch) - writer.i64(mandate.IssuedAt) - writer.i64(mandate.ExpiresAt) - writer.string(mandate.KeyID) - return writer.Bytes(), nil -} - -// Hash is the stable identifier of the mandate's signed content. It excludes -// only the transport encoding of Signature: the issuer key ID and every -// delegated field are part of Canonical and therefore the hash. -func (mandate Mandate) Hash() (string, error) { - canonical, err := mandate.Canonical() - if err != nil { - return "", err - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:]), nil -} - -func (mandate *Mandate) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid mandate private key length") - } - canonical, err := mandate.Canonical() - if err != nil { - return err - } - mandate.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (mandate Mandate) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := mandate.Canonical() - if err != nil { - return err - } - if err := verifyFresh("mandate", mandate.IssuedAt, mandate.ExpiresAt, now); err != nil { - return err - } - return verifySignature("mandate", publicKey, canonical, mandate.Signature) -} - -// MandateStore supplies locally retained signed mandates. A missing mandate is -// a denial; the store can immediately revoke one by deleting it before an -// updated tenant trust bundle propagates. -type MandateStore interface { - Mandate(context.Context, string, string) (Mandate, error) -} - -// MandateKeyResolver resolves a current tenant-delegated mandate issuer key. -// Implementations normally also satisfy MandateStateResolver via the same -// root-pinned trust store. -type MandateKeyResolver interface { - MandateKey(context.Context, string, string) (ed25519.PublicKey, error) -} - -type MandateStateResolver interface { - MinimumState(context.Context, string) (policyRevision, revocationEpoch uint64, err error) -} - -// MandateCeiling composes mandate checks ahead of the deterministic policy -// ceiling. It cannot create authority: it requires a valid signed mandate, -// makes its restrictions part of the exact Intent, then delegates to Next. -type MandateCeiling struct { - Store MandateStore - Keys MandateKeyResolver - Next AuthorityCeiling - Now func() time.Time -} - -func (ceiling MandateCeiling) Check(ctx context.Context, intent Intent, result Decision) error { - return ceiling.check(ctx, intent, result, nil) -} - -// CheckDisclosure preserves the mandate's exact audience and purpose ceiling, -// then delegates typed disclosure conditions to the next local policy ceiling. -// It never falls back to Next.Check when a disclosure is present. -func (ceiling MandateCeiling) CheckDisclosure(ctx context.Context, intent Intent, result Decision, disclosure DisclosureBinding) error { - if err := disclosure.VerifyIntent(intent); err != nil { - return err - } - return ceiling.check(ctx, intent, result, &disclosure) -} - -func (ceiling MandateCeiling) check(ctx context.Context, intent Intent, result Decision, disclosure *DisclosureBinding) error { - if ceiling.Store == nil || ceiling.Keys == nil { - return fmt.Errorf("decision: mandate ceiling is not initialized") - } - if intent.MandateID == "" { - return fmt.Errorf("decision: mandate is required") - } - mandate, err := ceiling.Store.Mandate(ctx, intent.TenantID, intent.MandateID) - if err != nil { - return fmt.Errorf("decision: resolve mandate: %w", err) - } - now := time.Now() - if ceiling.Now != nil { - now = ceiling.Now() - } - issuer, err := ceiling.Keys.MandateKey(ctx, intent.TenantID, mandate.KeyID) - if err != nil { - return fmt.Errorf("decision: resolve mandate key: %w", err) - } - if err := mandate.Verify(issuer, now); err != nil { - return err - } - if state, ok := ceiling.Keys.(MandateStateResolver); ok { - _, revocationEpoch, stateErr := state.MinimumState(ctx, intent.TenantID) - if stateErr != nil { - return fmt.Errorf("decision: resolve mandate state: %w", stateErr) - } - if mandate.RevocationEpoch < revocationEpoch { - return fmt.Errorf("decision: mandate revocation epoch %d is stale", mandate.RevocationEpoch) - } - } - if err := mandate.Check(intent, result); err != nil { - return err - } - if ceiling.Next != nil { - if disclosure != nil { - next, supported := ceiling.Next.(DisclosureCeiling) - if !supported { - return fmt.Errorf("decision: next mandate ceiling does not support disclosure binding") - } - return next.CheckDisclosure(ctx, intent, result, *disclosure) - } - return ceiling.Next.Check(ctx, intent, result) - } - return nil -} - -// Check ensures that result cannot exceed the signed delegation. Denials and -// approval-required results are always narrower than an executable result; -// execution after a threshold approval must be represented as a fresh Intent -// and Decision that still satisfies the mandate's constraints. -func (mandate Mandate) Check(intent Intent, result Decision) error { - if mandate.ID != intent.MandateID || mandate.TenantID != intent.TenantID || mandate.SubjectAgentID != intent.AgentID { - return fmt.Errorf("decision: mandate binding mismatch") - } - if !mandateAllowsAction(mandate.Actions, intent.Action) { - return fmt.Errorf("decision: mandate does not allow action %q", intent.Action) - } - if !mandateAllowsResource(mandate.ResourcePrefixes, intent.Resource) { - return fmt.Errorf("decision: mandate does not allow resource %q", intent.Resource) - } - if intent.Audience == "" || (mandate.Audience != "*" && mandate.Audience != intent.Audience) { - return fmt.Errorf("decision: mandate audience binding mismatch") - } - if mandate.Purpose != intent.Purpose { - return fmt.Errorf("decision: mandate purpose binding mismatch") - } - switch result.Outcome { - case Deny, ApprovalRequired: - return nil - case Allow, Constrain: - if mandate.RequiredApprovals > 0 { - return fmt.Errorf("decision: mandate requires %d approvals", mandate.RequiredApprovals) - } - if len(mandate.Constraints) == 0 { - return nil - } - if result.Outcome != Constrain || !containsMandateConstraints(result.Constraints, mandate.Constraints) { - return fmt.Errorf("decision: decision drops mandate constraints") - } - return nil - default: - return fmt.Errorf("decision: unsupported decision outcome %q", result.Outcome) - } -} - -func validMandateAction(value string) bool { - if value == "*" { - return true - } - if strings.HasSuffix(value, ".*") { - return validAction(strings.TrimSuffix(value, ".*")) - } - return validAction(value) -} - -func validMandateResource(value string) bool { - return value == "*" || validateText("mandate resource", value, 1024, false) == nil -} - -func validMandateAudience(value string) bool { - return value == "*" || validateIdentifier("mandate audience", value) == nil -} - -func mandateAllowsAction(patterns []string, action string) bool { - for _, pattern := range patterns { - if pattern == "*" || pattern == action || (strings.HasSuffix(pattern, ".*") && strings.HasPrefix(action, strings.TrimSuffix(pattern, "*"))) { - return true - } - } - return false -} - -func mandateAllowsResource(prefixes []string, resource string) bool { - for _, prefix := range prefixes { - if prefix == "*" || strings.HasPrefix(resource, prefix) { - return true - } - } - return false -} - -func containsMandateConstraints(got, required []Constraint) bool { - for _, requiredConstraint := range required { - found := false - for _, candidate := range got { - if candidate == requiredConstraint { - found = true - break - } - } - if !found { - return false - } - } - return true -} diff --git a/decision/mandate_bundle.go b/decision/mandate_bundle.go deleted file mode 100644 index 4767e47..0000000 --- a/decision/mandate_bundle.go +++ /dev/null @@ -1,161 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "sort" - "time" -) - -const ( - MandateBundleDomain = "pilot-mandate-bundle-v1" - MaxMandateBundleTTL = 24 * time.Hour - MaxBundleMandates = 256 -) - -// MandateBundle is the revisioned, signed distribution snapshot for one -// workload. A newer valid bundle replaces the whole prior set. Consequently, -// a mandate that is absent from a higher revision is revoked at the next local -// refresh, without relying on an unsafe mutable cache-delete signal. -// -// The contained Mandates remain independently signed and verifiable. The -// bundle signature binds their canonical hashes, agent scope, revision, and -// revocation epoch so an authority endpoint cannot splice valid mandates from -// different snapshots or replay an older set after a newer one is installed. -type MandateBundle struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - SubjectAgentID string `json:"subject_agent_id"` - Revision uint64 `json:"revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - Mandates []Mandate `json:"mandates"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (bundle MandateBundle) Validate() error { - if bundle.Version != SchemaVersion { - return fmt.Errorf("decision: mandate bundle version %d is unsupported", bundle.Version) - } - for name, value := range map[string]string{ - "tenant_id": bundle.TenantID, "subject_agent_id": bundle.SubjectAgentID, "key_id": bundle.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if bundle.Revision == 0 || bundle.RevocationEpoch == 0 { - return fmt.Errorf("decision: mandate bundle revision and revocation epoch must be positive") - } - if len(bundle.Mandates) > MaxBundleMandates { - return fmt.Errorf("decision: mandate bundle has more than %d mandates", MaxBundleMandates) - } - if bundle.IssuedAt <= 0 || bundle.ExpiresAt <= bundle.IssuedAt || bundle.ExpiresAt-bundle.IssuedAt > int64(MaxMandateBundleTTL/time.Second) { - return fmt.Errorf("decision: mandate bundle validity window is invalid") - } - seen := make(map[string]struct{}, len(bundle.Mandates)) - for _, mandate := range bundle.Mandates { - if err := mandate.Validate(); err != nil { - return fmt.Errorf("decision: invalid bundled mandate: %w", err) - } - if mandate.TenantID != bundle.TenantID || mandate.SubjectAgentID != bundle.SubjectAgentID { - return fmt.Errorf("decision: bundled mandate scope does not match bundle") - } - if mandate.RevocationEpoch < bundle.RevocationEpoch { - return fmt.Errorf("decision: bundled mandate %q is below bundle revocation epoch", mandate.ID) - } - if _, found := seen[mandate.ID]; found { - return fmt.Errorf("decision: duplicate bundled mandate %q", mandate.ID) - } - seen[mandate.ID] = struct{}{} - } - return nil -} - -func (bundle MandateBundle) Canonical() ([]byte, error) { - if err := bundle.Validate(); err != nil { - return nil, err - } - mandates := append([]Mandate(nil), bundle.Mandates...) - sort.Slice(mandates, func(left, right int) bool { return mandates[left].ID < mandates[right].ID }) - writer := canonicalWriter{} - writer.string(MandateBundleDomain) - writer.u16(bundle.Version) - writer.string(bundle.TenantID) - writer.string(bundle.SubjectAgentID) - writer.u64(bundle.Revision) - writer.u64(bundle.RevocationEpoch) - writer.u16(uint16(len(mandates))) - for _, mandate := range mandates { - hash, err := mandate.Hash() - if err != nil { - return nil, err - } - writer.string(mandate.ID) - writer.string(hash) - } - writer.i64(bundle.IssuedAt) - writer.i64(bundle.ExpiresAt) - writer.string(bundle.KeyID) - return writer.Bytes(), nil -} - -// Hash identifies the exact revisioned delegation snapshot for idempotent -// publication and durable anti-rollback records. -func (bundle MandateBundle) Hash() (string, error) { - canonical, err := bundle.Canonical() - if err != nil { - return "", err - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:]), nil -} - -func (bundle *MandateBundle) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid mandate bundle private key length") - } - canonical, err := bundle.Canonical() - if err != nil { - return err - } - bundle.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -// Verify verifies the snapshot issuer and every contained mandate at the same -// point in time. A caller must still compare Revision to its durable local -// floor before replacing an installed snapshot. -func (bundle MandateBundle) Verify(ctx context.Context, publicKey ed25519.PublicKey, mandateKey MandateKeyResolver, now time.Time) error { - canonical, err := bundle.Canonical() - if err != nil { - return err - } - if err := verifyFresh("mandate bundle", bundle.IssuedAt, bundle.ExpiresAt, now); err != nil { - return err - } - if err := verifySignature("mandate bundle", publicKey, canonical, bundle.Signature); err != nil { - return err - } - if mandateKey == nil { - return fmt.Errorf("decision: mandate bundle key resolver is required") - } - for _, mandate := range bundle.Mandates { - issuer, keyErr := mandateKey.MandateKey(ctx, bundle.TenantID, mandate.KeyID) - if keyErr != nil { - return fmt.Errorf("decision: resolve bundled mandate %q issuer: %w", mandate.ID, keyErr) - } - if verifyErr := mandate.Verify(issuer, now); verifyErr != nil { - return fmt.Errorf("decision: verify bundled mandate %q: %w", mandate.ID, verifyErr) - } - } - return nil -} diff --git a/decision/mandate_store.go b/decision/mandate_store.go deleted file mode 100644 index f5ebb4a..0000000 --- a/decision/mandate_store.go +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "fmt" - "time" -) - -// StaticMandateStore holds a reviewed snapshot of signed mandates. It is -// deliberately immutable: configuration reloads replace the complete store, -// while every enforcement check still revalidates the mandate against current -// trust and revocation state through MandateCeiling. -type StaticMandateStore struct { - mandates map[string]Mandate -} - -// NewStaticMandateStore verifies a tenant-scoped mandate snapshot before it -// can be attached to an enforcement point. An empty snapshot is rejected so a -// configuration that claims to require delegation cannot silently become -// permissive. -func NewStaticMandateStore(ctx context.Context, tenantID string, mandates []Mandate, keys MandateKeyResolver, state MandateStateResolver, now time.Time) (*StaticMandateStore, error) { - if err := validateIdentifier("tenant_id", tenantID); err != nil { - return nil, err - } - if len(mandates) == 0 { - return nil, fmt.Errorf("decision: at least one mandate is required") - } - if keys == nil || state == nil { - return nil, fmt.Errorf("decision: mandate key and state resolvers are required") - } - if now.IsZero() { - now = time.Now() - } - _, revocationEpoch, err := state.MinimumState(ctx, tenantID) - if err != nil { - return nil, fmt.Errorf("decision: resolve mandate state: %w", err) - } - store := &StaticMandateStore{mandates: make(map[string]Mandate, len(mandates))} - for _, mandate := range mandates { - if mandate.TenantID != tenantID { - return nil, fmt.Errorf("decision: mandate %q has a different tenant", mandate.ID) - } - if mandate.RevocationEpoch < revocationEpoch { - return nil, fmt.Errorf("decision: mandate %q is below the active revocation epoch", mandate.ID) - } - issuer, keyErr := keys.MandateKey(ctx, tenantID, mandate.KeyID) - if keyErr != nil { - return nil, fmt.Errorf("decision: resolve mandate %q issuer: %w", mandate.ID, keyErr) - } - if verifyErr := mandate.Verify(issuer, now); verifyErr != nil { - return nil, fmt.Errorf("decision: verify mandate %q: %w", mandate.ID, verifyErr) - } - if _, exists := store.mandates[mandate.ID]; exists { - return nil, fmt.Errorf("decision: duplicate mandate %q", mandate.ID) - } - store.mandates[mandate.ID] = cloneMandate(mandate) - } - return store, nil -} - -// NewStaticMandateStoreFromBundle verifies a complete signed distribution -// snapshot before exposing its mandates to an enforcement point. Empty bundles -// are valid and intentionally create an empty, fail-closed delegation set. -func NewStaticMandateStoreFromBundle(ctx context.Context, bundle MandateBundle, keys MandateKeyResolver, state MandateStateResolver, now time.Time) (*StaticMandateStore, error) { - if keys == nil || state == nil { - return nil, fmt.Errorf("decision: mandate key and state resolvers are required") - } - if now.IsZero() { - now = time.Now() - } - bundleKey, err := keys.MandateKey(ctx, bundle.TenantID, bundle.KeyID) - if err != nil { - return nil, fmt.Errorf("decision: resolve mandate bundle issuer: %w", err) - } - if err := bundle.Verify(ctx, bundleKey, keys, now); err != nil { - return nil, err - } - _, revocationEpoch, err := state.MinimumState(ctx, bundle.TenantID) - if err != nil { - return nil, fmt.Errorf("decision: resolve mandate state: %w", err) - } - if bundle.RevocationEpoch < revocationEpoch { - return nil, fmt.Errorf("decision: mandate bundle revocation epoch %d is stale", bundle.RevocationEpoch) - } - store := &StaticMandateStore{mandates: make(map[string]Mandate, len(bundle.Mandates))} - for _, mandate := range bundle.Mandates { - store.mandates[mandate.ID] = cloneMandate(mandate) - } - return store, nil -} - -// Mandate returns an independent copy so a caller cannot alter the retained -// signed delegation between checks. -func (store *StaticMandateStore) Mandate(_ context.Context, tenantID, mandateID string) (Mandate, error) { - if store == nil { - return Mandate{}, fmt.Errorf("decision: mandate store is not initialized") - } - mandate, found := store.mandates[mandateID] - if !found || mandate.TenantID != tenantID { - return Mandate{}, fmt.Errorf("decision: mandate %q is not available", mandateID) - } - return cloneMandate(mandate), nil -} - -func cloneMandate(mandate Mandate) Mandate { - mandate.Actions = append([]string(nil), mandate.Actions...) - mandate.ResourcePrefixes = append([]string(nil), mandate.ResourcePrefixes...) - mandate.Constraints = append([]Constraint(nil), mandate.Constraints...) - return mandate -} diff --git a/decision/mandate_test.go b/decision/mandate_test.go deleted file mode 100644 index 96cdb2c..0000000 --- a/decision/mandate_test.go +++ /dev/null @@ -1,246 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/json" - "strings" - "testing" - "time" -) - -type mandateStoreStub struct { - mandate Mandate - err error -} - -func (store mandateStoreStub) Mandate(context.Context, string, string) (Mandate, error) { - return store.mandate, store.err -} - -type mandateKeyStub struct { - key ed25519.PublicKey - revocationEpoch uint64 -} - -func (trust mandateKeyStub) MandateKey(context.Context, string, string) (ed25519.PublicKey, error) { - return trust.key, nil -} - -func (trust mandateKeyStub) MinimumState(context.Context, string) (uint64, uint64, error) { - return 1, trust.revocationEpoch, nil -} - -func delegatedMandateFixture(t *testing.T) (Mandate, Intent, Decision, ed25519.PublicKey, ed25519.PrivateKey, time.Time) { - t.Helper() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Now().UTC().Truncate(time.Second) - mandate := Mandate{ - Version: SchemaVersion, ID: "mandate-finance-1", TenantID: "tenant-acme", SubjectAgentID: "agent-buyer-1", - Actions: []string{"wallet.pay"}, ResourcePrefixes: []string{"merchant:approved/"}, Audience: "agent:vendor-a", Purpose: "invoice-42", - Constraints: []Constraint{{Key: "amount_usdc", Operator: "max", Value: "100"}}, RevocationEpoch: 3, - IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), KeyID: "mandate-key-1", - } - if err := mandate.Sign(privateKey); err != nil { - t.Fatal(err) - } - nonce, err := NewNonce() - if err != nil { - t.Fatal(err) - } - intent := Intent{ - Version: SchemaVersion, ID: "intent-mandate-1", TenantID: mandate.TenantID, AgentID: mandate.SubjectAgentID, - Action: "wallet.pay", Resource: "merchant:approved/vendor-a", MandateID: mandate.ID, Audience: mandate.Audience, Purpose: mandate.Purpose, - PayloadHash: HashPayload([]byte(`{"amount":"25"}`)), Risk: RiskHigh, IssuedAt: now.Unix(), ExpiresAt: now.Add(2 * time.Minute).Unix(), Nonce: nonce, KeyID: "agent-key-1", - } - intentHash, err := intent.Hash() - if err != nil { - t.Fatal(err) - } - result := Decision{ - Version: SchemaVersion, ID: "decision-mandate-1", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, - Outcome: Constrain, Constraints: append([]Constraint(nil), mandate.Constraints...), PolicyRevision: 1, RevocationEpoch: mandate.RevocationEpoch, - ProviderID: "managed", IssuedAt: now.Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "decision-key-1", - } - return mandate, intent, result, publicKey, privateKey, now -} - -func TestMandateCanonicalSignatureAndScope(t *testing.T) { - mandate, intent, result, publicKey, _, now := delegatedMandateFixture(t) - if err := mandate.Verify(publicKey, now); err != nil { - t.Fatalf("verify mandate: %v", err) - } - if err := mandate.Check(intent, result); err != nil { - t.Fatalf("check mandate: %v", err) - } - tampered := mandate - tampered.Purpose = "other-invoice" - if err := tampered.Verify(publicKey, now); err == nil || !strings.Contains(err.Error(), "signature") { - t.Fatalf("tampered mandate error=%v", err) - } - wrongAudience := intent - wrongAudience.Audience = "agent:vendor-b" - if err := mandate.Check(wrongAudience, result); err == nil || !strings.Contains(err.Error(), "audience") { - t.Fatalf("audience error=%v", err) - } - missingConstraint := result - missingConstraint.Constraints = nil - missingConstraint.Outcome = Allow - if err := mandate.Check(intent, missingConstraint); err == nil || !strings.Contains(err.Error(), "constraints") { - t.Fatalf("constraint error=%v", err) - } -} - -func TestMandateCeilingFailsClosedOnMissingOrStaleMandate(t *testing.T) { - mandate, intent, result, publicKey, _, now := delegatedMandateFixture(t) - ceiling := MandateCeiling{ - Store: mandateStoreStub{mandate: mandate}, Keys: mandateKeyStub{key: publicKey, revocationEpoch: 3}, - Next: ceilingFunc(func(context.Context, Intent, Decision) error { return nil }), Now: func() time.Time { return now }, - } - if err := ceiling.Check(context.Background(), intent, result); err != nil { - t.Fatalf("valid mandate ceiling rejected: %v", err) - } - missing := intent - missing.MandateID = "" - if err := ceiling.Check(context.Background(), missing, result); err == nil || !strings.Contains(err.Error(), "required") { - t.Fatalf("missing mandate error=%v", err) - } - stale := MandateCeiling{ - Store: mandateStoreStub{mandate: mandate}, Keys: mandateKeyStub{key: publicKey, revocationEpoch: 4}, - Now: func() time.Time { return now }, - } - if err := stale.Check(context.Background(), intent, result); err == nil || !strings.Contains(err.Error(), "stale") { - t.Fatalf("stale mandate error=%v", err) - } -} - -func TestDelegatedIntentAndReceiptCannotBeReplayedAsUnmandated(t *testing.T) { - mandate, intent, result, _, privateKey, now := delegatedMandateFixture(t) - if err := intent.Sign(privateKey); err != nil { - t.Fatal(err) - } - copyIntent := intent - copyIntent.MandateID = "" - if err := copyIntent.Verify(privateKey.Public().(ed25519.PublicKey), now); err == nil || !strings.Contains(err.Error(), "signature") { - t.Fatalf("unmandated replay error=%v", err) - } - receipt, err := NewReceipt(intent, result, "wallet", "receipt-key", now.Unix(), Enforced) - if err != nil { - t.Fatal(err) - } - if receipt.MandateID != mandate.ID { - t.Fatalf("receipt mandate=%q, want %q", receipt.MandateID, mandate.ID) - } - if err := receipt.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := receipt.VerifyFor(intent, result, privateKey.Public().(ed25519.PublicKey)); err != nil { - t.Fatalf("delegated receipt rejected: %v", err) - } - unmandated := intent - unmandated.MandateID = "" - if err := receipt.VerifyFor(unmandated, result, privateKey.Public().(ed25519.PublicKey)); err == nil { - t.Fatal("receipt replayed to unmandated intent") - } -} - -func TestStaticMandateStoreVerifiesSnapshotAndReturnsIndependentCopies(t *testing.T) { - mandate, _, _, publicKey, _, now := delegatedMandateFixture(t) - store, err := NewStaticMandateStore(context.Background(), mandate.TenantID, []Mandate{mandate}, mandateKeyStub{key: publicKey, revocationEpoch: mandate.RevocationEpoch}, mandateKeyStub{key: publicKey, revocationEpoch: mandate.RevocationEpoch}, now) - if err != nil { - t.Fatalf("new mandate store: %v", err) - } - first, err := store.Mandate(context.Background(), mandate.TenantID, mandate.ID) - if err != nil { - t.Fatalf("read mandate: %v", err) - } - first.Actions[0] = "tampered" - second, err := store.Mandate(context.Background(), mandate.TenantID, mandate.ID) - if err != nil || second.Actions[0] != "wallet.pay" { - t.Fatalf("mandate snapshot mutated: %+v, err=%v", second, err) - } - if _, err := NewStaticMandateStore(context.Background(), mandate.TenantID, []Mandate{mandate, mandate}, mandateKeyStub{key: publicKey, revocationEpoch: mandate.RevocationEpoch}, mandateKeyStub{key: publicKey, revocationEpoch: mandate.RevocationEpoch}, now); err == nil || !strings.Contains(err.Error(), "duplicate") { - t.Fatalf("duplicate mandate error=%v", err) - } - if _, err := NewStaticMandateStore(context.Background(), mandate.TenantID, []Mandate{mandate}, mandateKeyStub{key: publicKey, revocationEpoch: mandate.RevocationEpoch + 1}, mandateKeyStub{key: publicKey, revocationEpoch: mandate.RevocationEpoch + 1}, now); err == nil || !strings.Contains(err.Error(), "below") { - t.Fatalf("stale mandate snapshot error=%v", err) - } -} - -func TestMandateBundleBindsRevisionAndSupportsFailClosedRemoval(t *testing.T) { - mandate, _, _, publicKey, privateKey, now := delegatedMandateFixture(t) - bundle := MandateBundle{ - Version: SchemaVersion, TenantID: mandate.TenantID, SubjectAgentID: mandate.SubjectAgentID, - Revision: 7, RevocationEpoch: mandate.RevocationEpoch, Mandates: []Mandate{mandate}, - IssuedAt: now.Unix(), ExpiresAt: now.Add(10 * time.Minute).Unix(), KeyID: mandate.KeyID, - } - if err := bundle.Sign(privateKey); err != nil { - t.Fatal(err) - } - keys := mandateKeyStub{key: publicKey, revocationEpoch: mandate.RevocationEpoch} - if err := bundle.Verify(context.Background(), publicKey, keys, now); err != nil { - t.Fatalf("verify bundle: %v", err) - } - store, err := NewStaticMandateStoreFromBundle(context.Background(), bundle, keys, keys, now) - if err != nil { - t.Fatalf("bundle store: %v", err) - } - if _, err := store.Mandate(context.Background(), mandate.TenantID, mandate.ID); err != nil { - t.Fatalf("bundled mandate not available: %v", err) - } - tampered := bundle - tampered.Mandates = append([]Mandate(nil), bundle.Mandates...) - tampered.Mandates[0].Purpose = "tampered-purpose" - if err := tampered.Verify(context.Background(), publicKey, keys, now); err == nil || !strings.Contains(err.Error(), "signature") { - t.Fatalf("tampered bundle error=%v", err) - } - - removal := MandateBundle{ - Version: SchemaVersion, TenantID: mandate.TenantID, SubjectAgentID: mandate.SubjectAgentID, - Revision: 8, RevocationEpoch: mandate.RevocationEpoch, IssuedAt: now.Unix(), ExpiresAt: now.Add(10 * time.Minute).Unix(), KeyID: mandate.KeyID, - } - if err := removal.Sign(privateKey); err != nil { - t.Fatal(err) - } - removed, err := NewStaticMandateStoreFromBundle(context.Background(), removal, keys, keys, now) - if err != nil { - t.Fatalf("empty removal bundle: %v", err) - } - if _, err := removed.Mandate(context.Background(), mandate.TenantID, mandate.ID); err == nil { - t.Fatal("removed mandate remained available") - } -} - -func FuzzMandateBundleCanonicalization(f *testing.F) { - f.Add([]byte(`{"version":1}`)) - f.Add([]byte(`{"version":1,"tenant_id":"tenant-a","subject_agent_id":"agent-a","revision":1,"revocation_epoch":1,"issued_at":1,"expires_at":2,"key_id":"issuer-a"}`)) - f.Fuzz(func(t *testing.T, body []byte) { - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - var bundle MandateBundle - if err := decoder.Decode(&bundle); err != nil { - return - } - var trailing any - if err := decoder.Decode(&trailing); err == nil { - return - } - if err := bundle.Validate(); err != nil { - return - } - canonical, err := bundle.Canonical() - if err != nil || len(canonical) == 0 { - t.Fatalf("valid bundle canonicalization failed: bytes=%d err=%v", len(canonical), err) - } - if _, err := bundle.Hash(); err != nil { - t.Fatalf("valid bundle hash failed: %v", err) - } - }) -} diff --git a/decision/model_usage.go b/decision/model_usage.go deleted file mode 100644 index 6bbff7e..0000000 --- a/decision/model_usage.go +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "fmt" - "sync" -) - -// ModelUsage is provider-reported usage for one hosted semantic evaluation. -// It is attached out-of-band to the semantic response and copied into the -// durable evaluation record; it never influences authorization authority. -type ModelUsage struct { - ModelCalls uint64 `json:"model_calls,omitempty"` - InputTokens uint64 `json:"input_tokens,omitempty"` - OutputTokens uint64 `json:"output_tokens,omitempty"` -} - -func (usage ModelUsage) Validate() error { - if usage.ModelCalls > 16 || usage.InputTokens > 100_000_000 || usage.OutputTokens > 10_000_000 { - return fmt.Errorf("decision: invalid model usage") - } - if usage.ModelCalls == 0 && (usage.InputTokens != 0 || usage.OutputTokens != 0) { - return fmt.Errorf("decision: model tokens require a model call") - } - return nil -} - -type modelUsageContextKey struct{} - -// ModelUsageRecorder is scoped to one authorization attempt. The HTTP -// semantic client reports the provider response into it, and the guarded -// authorizer snapshots it before persisting the evaluation. -type ModelUsageRecorder struct { - mu sync.Mutex - usage ModelUsage -} - -func WithModelUsageRecorder(ctx context.Context) (context.Context, *ModelUsageRecorder) { - recorder := &ModelUsageRecorder{} - return context.WithValue(ctx, modelUsageContextKey{}, recorder), recorder -} - -func ReportModelUsage(ctx context.Context, usage ModelUsage) error { - if err := usage.Validate(); err != nil { - return err - } - recorder, ok := ctx.Value(modelUsageContextKey{}).(*ModelUsageRecorder) - if !ok || recorder == nil { - return nil - } - recorder.mu.Lock() - defer recorder.mu.Unlock() - if recorder.usage.ModelCalls != 0 { - return fmt.Errorf("decision: model usage already reported") - } - recorder.usage = usage - return nil -} - -func (recorder *ModelUsageRecorder) Snapshot() ModelUsage { - if recorder == nil { - return ModelUsage{} - } - recorder.mu.Lock() - defer recorder.mu.Unlock() - return recorder.usage -} diff --git a/decision/presidio.go b/decision/presidio.go deleted file mode 100644 index 4ba8b70..0000000 --- a/decision/presidio.go +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "strings" - "time" -) - -const ( - // DefaultPresidioMaxBytes bounds the local buffering needed to call - // Presidio's JSON API. Larger governed objects should be scanned by a - // streaming local implementation rather than silently truncated. - DefaultPresidioMaxBytes int64 = 1 << 20 - maxPresidioBytes int64 = 8 << 20 - defaultPresidioScore = 0.7 -) - -// PresidioInspector implements DisclosureContentInspector using a local -// Presidio Analyzer service. Endpoint is the base service URL; the inspector -// calls its /analyze endpoint. Run this service on the enforcement host or a -// private tenant network with transport authentication. It is deliberately an -// inspection-only hook: any detector result or service failure rejects the -// delivery, but a clean result never expands the signed Decision. -// -// The adapter accepts only textual and structured-text content types. A -// required profile consequently fails closed for binary or image files unless -// the operator provides an appropriate local inspector for those formats. -type PresidioInspector struct { - Endpoint string - HTTPClient *http.Client - Language string - Entities []string - ScoreThreshold float64 - MaxBytes int64 -} - -type presidioAnalysisRequest struct { - Text string `json:"text"` - Language string `json:"language"` - Entities []string `json:"entities,omitempty"` -} - -type presidioFinding struct { - EntityType string `json:"entity_type"` - Score float64 `json:"score"` -} - -// Validate checks the static inspector configuration before a transport starts. -// It intentionally does not make a network call; service availability remains -// a delivery-time fail-closed condition. -func (inspector PresidioInspector) Validate() error { - if _, err := inspector.analyzeURL(); err != nil { - return err - } - if _, err := inspector.maxBytes(); err != nil { - return err - } - _, err := inspector.scoreThreshold() - return err -} - -// InspectDisclosureContent rejects the content when Presidio reports any -// finding at or above the configured score threshold. Its errors contain no -// inspected content or detection spans. -func (inspector PresidioInspector) InspectDisclosureContent(ctx context.Context, _ Intent, _ *DisclosureBinding, contentType, _ string, content io.Reader) error { - analyzeURL, err := inspector.analyzeURL() - if err != nil { - return err - } - if !presidioTextContentType(contentType) { - return fmt.Errorf("presidio: content type %q cannot be inspected as text", contentType) - } - maxBytes, err := inspector.maxBytes() - if err != nil { - return err - } - body, err := io.ReadAll(io.LimitReader(content, maxBytes+1)) - if err != nil { - return fmt.Errorf("presidio: read content: %w", err) - } - if int64(len(body)) > maxBytes { - return fmt.Errorf("presidio: content exceeds local inspection limit") - } - language := inspector.Language - if language == "" { - language = "en" - } - encoded, err := json.Marshal(presidioAnalysisRequest{Text: string(body), Language: language, Entities: inspector.Entities}) - if err != nil { - return fmt.Errorf("presidio: encode analysis request: %w", err) - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, analyzeURL, bytes.NewReader(encoded)) - if err != nil { - return fmt.Errorf("presidio: create analysis request: %w", err) - } - request.Header.Set("Content-Type", "application/json") - response, err := inspector.httpClient().Do(request) - if err != nil { - return fmt.Errorf("presidio: analysis request: %w", err) - } - defer response.Body.Close() - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8<<10)) - return fmt.Errorf("presidio: analyzer returned status %d", response.StatusCode) - } - var findings []presidioFinding - if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&findings); err != nil { - return fmt.Errorf("presidio: decode analysis response: %w", err) - } - threshold, err := inspector.scoreThreshold() - if err != nil { - return err - } - for _, finding := range findings { - if finding.Score >= threshold { - return fmt.Errorf("presidio: sensitive content detected") - } - } - return nil -} - -func (inspector PresidioInspector) analyzeURL() (string, error) { - base := strings.TrimSpace(inspector.Endpoint) - if base == "" { - return "", fmt.Errorf("presidio: endpoint is required") - } - parsed, err := url.Parse(base) - if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { - return "", fmt.Errorf("presidio: endpoint must be an absolute HTTP(S) URL without credentials") - } - parsed.Path = strings.TrimRight(parsed.Path, "/") + "/analyze" - parsed.RawQuery = "" - parsed.Fragment = "" - return parsed.String(), nil -} - -func (inspector PresidioInspector) maxBytes() (int64, error) { - if inspector.MaxBytes == 0 { - return DefaultPresidioMaxBytes, nil - } - if inspector.MaxBytes < 1 || inspector.MaxBytes > maxPresidioBytes { - return 0, fmt.Errorf("presidio: max bytes must be 1-%d", maxPresidioBytes) - } - return inspector.MaxBytes, nil -} - -func (inspector PresidioInspector) scoreThreshold() (float64, error) { - if inspector.ScoreThreshold == 0 { - return defaultPresidioScore, nil - } - if inspector.ScoreThreshold < 0 || inspector.ScoreThreshold > 1 { - return 0, fmt.Errorf("presidio: score threshold must be within 0-1") - } - return inspector.ScoreThreshold, nil -} - -func (inspector PresidioInspector) httpClient() *http.Client { - if inspector.HTTPClient != nil { - return inspector.HTTPClient - } - return &http.Client{ - Timeout: 5 * time.Second, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - } -} - -func presidioTextContentType(contentType string) bool { - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil { - return false - } - if strings.HasPrefix(mediaType, "text/") { - return true - } - switch mediaType { - case "application/json", "application/ld+json", "application/xml", "application/x-www-form-urlencoded", "application/yaml", "application/x-yaml": - return true - default: - return false - } -} diff --git a/decision/presidio_external_test.go b/decision/presidio_external_test.go deleted file mode 100644 index ba68c63..0000000 --- a/decision/presidio_external_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "os" - "strings" - "testing" -) - -func TestPresidioExternalIntegration(t *testing.T) { - endpoint := os.Getenv("PILOT_TEST_PRESIDIO_ENDPOINT") - if endpoint == "" { - t.Skip("set PILOT_TEST_PRESIDIO_ENDPOINT to run against a local Presidio Analyzer") - } - inspector := PresidioInspector{Endpoint: endpoint, Entities: []string{"EMAIL_ADDRESS"}} - if err := inspector.InspectDisclosureContent(context.Background(), Intent{}, nil, "text/plain", "", strings.NewReader("the blue bicycle is parked")); err != nil { - t.Fatalf("clean content rejected: %v", err) - } - if err := inspector.InspectDisclosureContent(context.Background(), Intent{}, nil, "text/plain", "", strings.NewReader("contact pilot@example.com")); err == nil || err.Error() != "presidio: sensitive content detected" { - t.Fatalf("email content inspection error=%v", err) - } -} diff --git a/decision/presidio_test.go b/decision/presidio_test.go deleted file mode 100644 index 39999be..0000000 --- a/decision/presidio_test.go +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestPresidioInspectorRejectsDetectedContentWithoutLeakingIt(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.URL.Path != "/analyze" || request.Method != http.MethodPost { - t.Fatalf("request = %s %s", request.Method, request.URL.Path) - } - var body presidioAnalysisRequest - if err := json.NewDecoder(request.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if body.Text != "ssn 123-45-6789" || body.Language != "en" || len(body.Entities) != 1 || body.Entities[0] != "US_SSN" { - t.Fatalf("request body = %+v", body) - } - _, _ = io.WriteString(response, `[{"entity_type":"US_SSN","score":0.9}]`) - })) - defer server.Close() - - err := (PresidioInspector{Endpoint: server.URL, Entities: []string{"US_SSN"}}).InspectDisclosureContent(context.Background(), Intent{}, nil, "text/plain; charset=utf-8", "", strings.NewReader("ssn 123-45-6789")) - if err == nil || err.Error() != "presidio: sensitive content detected" || strings.Contains(err.Error(), "123-45-6789") { - t.Fatalf("inspection error = %v", err) - } -} - -func TestPresidioInspectorAllowsCleanContentAndFailsClosed(t *testing.T) { - calls := 0 - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - calls++ - switch calls { - case 1: - _, _ = io.WriteString(response, `[]`) - default: - http.Error(response, "unavailable", http.StatusServiceUnavailable) - } - })) - defer server.Close() - inspector := PresidioInspector{Endpoint: server.URL, ScoreThreshold: 0.8} - if err := inspector.InspectDisclosureContent(context.Background(), Intent{}, nil, "application/json", "", strings.NewReader(`{"safe":true}`)); err != nil { - t.Fatalf("clean inspection error = %v", err) - } - if err := inspector.InspectDisclosureContent(context.Background(), Intent{}, nil, "application/json", "", strings.NewReader(`{"safe":true}`)); err == nil || !strings.Contains(err.Error(), "analyzer returned status 503") { - t.Fatalf("unavailable inspection error = %v", err) - } - if err := inspector.InspectDisclosureContent(context.Background(), Intent{}, nil, "application/pdf", "", strings.NewReader("not sent")); err == nil || !strings.Contains(err.Error(), "cannot be inspected") { - t.Fatalf("binary inspection error = %v", err) - } - if calls != 2 { - t.Fatalf("unexpected analyzer calls = %d", calls) - } -} - -func TestPresidioInspectorBoundsContentBeforeSendingIt(t *testing.T) { - inspector := PresidioInspector{Endpoint: "http://127.0.0.1:1", MaxBytes: 4} - err := inspector.InspectDisclosureContent(context.Background(), Intent{}, nil, "text/plain", "", strings.NewReader("12345")) - if err == nil || !strings.Contains(err.Error(), "exceeds local inspection limit") { - t.Fatalf("oversize inspection error = %v", err) - } -} diff --git a/decision/receipt.go b/decision/receipt.go deleted file mode 100644 index 56f14fb..0000000 --- a/decision/receipt.go +++ /dev/null @@ -1,318 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" -) - -const ( - ReceiptDomain = "pilot-receipt-v1" - ReceiptDisclosureDomain = "pilot-receipt-v2" - ReceiptDisclosureVersion = uint16(2) -) - -type EnforcementResult string - -const ( - Enforced EnforcementResult = "enforced" - Denied EnforcementResult = "denied" - ApprovalPending EnforcementResult = "approval_pending" - Failed EnforcementResult = "failed" -) - -// Receipt is the signed local evidence that an enforcement point acted on a -// decision. Its deterministic ID is also the idempotency key for usage -// metering, so retrying delivery cannot create another billable unit. -type Receipt struct { - Version uint16 `json:"version"` - ID string `json:"id"` - DecisionID string `json:"decision_id"` - DecisionHash string `json:"decision_hash"` - IntentHash string `json:"intent_hash"` - // DisclosureHash is required only by receipt V2. It is the hash of the - // canonical DisclosureBinding, never application plaintext. - DisclosureHash string `json:"disclosure_hash,omitempty"` - TenantID string `json:"tenant_id"` - MandateID string `json:"mandate_id,omitempty"` - // AgentID identifies the local enforcement agent whose delegated receipt - // key signed this record. The initiating actor remains bound by IntentHash. - AgentID string `json:"agent_id"` - Outcome Outcome `json:"outcome"` - Result EnforcementResult `json:"result"` - EnforcementPoint string `json:"enforcement_point"` - ObservedAt int64 `json:"observed_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func NewReceipt(intent Intent, result Decision, enforcementPoint, keyID string, observedAt int64, enforced EnforcementResult) (Receipt, error) { - return NewReceiptForEnforcer(intent, result, intent.AgentID, enforcementPoint, keyID, observedAt, enforced) -} - -// NewReceiptForEnforcer creates evidence for an enforcement point operated by -// enforcementAgentID. This differs from the Intent actor for receiver-side -// transport controls: a broker or inbox must sign with its own delegated -// receipt key, not with the sender's key. NewReceipt remains the convenient -// same-agent form used by wallets and local tools. -func NewReceiptForEnforcer(intent Intent, result Decision, enforcementAgentID, enforcementPoint, keyID string, observedAt int64, enforced EnforcementResult) (Receipt, error) { - intentHash, err := intent.Hash() - if err != nil { - return Receipt{}, err - } - decisionHash, err := result.Hash() - if err != nil { - return Receipt{}, err - } - receiptID, err := ReceiptID(result.ID, enforcementPoint) - if err != nil { - return Receipt{}, err - } - receipt := Receipt{ - Version: SchemaVersion, - ID: receiptID, - DecisionID: result.ID, - DecisionHash: decisionHash, - IntentHash: intentHash, - TenantID: intent.TenantID, - MandateID: intent.MandateID, - AgentID: enforcementAgentID, - Outcome: result.Outcome, - Result: enforced, - EnforcementPoint: enforcementPoint, - ObservedAt: observedAt, - KeyID: keyID, - } - if err := receipt.Validate(); err != nil { - return Receipt{}, err - } - return receipt, nil -} - -// NewDisclosureReceiptForEnforcer creates V2 enforcement evidence for a -// disclosure-bound action. V1 receipt canonical bytes and verification remain -// unchanged; V2 adds only the signed canonical disclosure hash. -func NewDisclosureReceiptForEnforcer(intent Intent, result Decision, disclosure DisclosureBinding, enforcementAgentID, enforcementPoint, keyID string, observedAt int64, enforced EnforcementResult) (Receipt, error) { - if err := disclosure.VerifyIntent(intent); err != nil { - return Receipt{}, err - } - disclosureHash, err := disclosure.Hash() - if err != nil { - return Receipt{}, err - } - receipt, err := NewReceiptForEnforcer(intent, result, enforcementAgentID, enforcementPoint, keyID, observedAt, enforced) - if err != nil { - return Receipt{}, err - } - receipt.Version = ReceiptDisclosureVersion - receipt.DisclosureHash = disclosureHash - if err := receipt.Validate(); err != nil { - return Receipt{}, err - } - return receipt, nil -} - -func ReceiptID(decisionID, enforcementPoint string) (string, error) { - if err := validateIdentifier("decision id", decisionID); err != nil { - return "", err - } - if err := validateIdentifier("enforcement point", enforcementPoint); err != nil { - return "", err - } - w := canonicalWriter{} - w.string(ReceiptDomain + "/id") - w.string(decisionID) - w.string(enforcementPoint) - sum := sha256.Sum256(w.Bytes()) - return hex.EncodeToString(sum[:]), nil -} - -func (r Receipt) UsageUnitID() string { return r.ID } - -func (r Receipt) Validate() error { - if r.Version != SchemaVersion && r.Version != ReceiptDisclosureVersion { - return fmt.Errorf("decision: receipt version %d is unsupported", r.Version) - } - if r.Version == SchemaVersion && r.DisclosureHash != "" { - return fmt.Errorf("decision: V1 receipt must not carry disclosure_hash") - } - if r.Version == ReceiptDisclosureVersion && !lowerHex(r.DisclosureHash, 64) { - return fmt.Errorf("decision: V2 receipt requires disclosure_hash") - } - for name, value := range map[string]string{ - "decision id": r.DecisionID, "tenant_id": r.TenantID, "agent_id": r.AgentID, - "enforcement point": r.EnforcementPoint, "key_id": r.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if r.MandateID != "" { - if err := validateIdentifier("mandate_id", r.MandateID); err != nil { - return err - } - } - expectedID, err := ReceiptID(r.DecisionID, r.EnforcementPoint) - if err != nil { - return err - } - if r.ID != expectedID { - return fmt.Errorf("decision: receipt id is not canonical for decision and enforcement point") - } - if !lowerHex(r.DecisionHash, 64) || !lowerHex(r.IntentHash, 64) { - return fmt.Errorf("decision: receipt hashes must be 64 lowercase hex characters") - } - switch r.Outcome { - case Allow, Deny, Constrain, ApprovalRequired: - default: - return fmt.Errorf("decision: invalid receipt outcome %q", r.Outcome) - } - switch r.Result { - case Enforced, Denied, ApprovalPending, Failed: - default: - return fmt.Errorf("decision: invalid enforcement result %q", r.Result) - } - switch r.Outcome { - case Deny: - if r.Result != Denied { - return fmt.Errorf("decision: deny outcome requires denied enforcement result") - } - case ApprovalRequired: - if r.Result != ApprovalPending { - return fmt.Errorf("decision: approval_required outcome requires approval_pending result") - } - case Allow, Constrain: - if r.Result != Enforced && r.Result != Failed { - return fmt.Errorf("decision: allow/constrain outcome requires enforced or failed result") - } - } - if r.ObservedAt <= 0 { - return fmt.Errorf("decision: invalid receipt observation time") - } - return nil -} - -func (r Receipt) Canonical() ([]byte, error) { - if err := r.Validate(); err != nil { - return nil, err - } - w := canonicalWriter{} - domain := ReceiptDomain - if r.Version == ReceiptDisclosureVersion { - domain = ReceiptDisclosureDomain - } - if r.MandateID != "" { - w.string(domain + "/delegated") - } else { - w.string(domain) - } - w.u16(r.Version) - w.string(r.ID) - w.string(r.DecisionID) - w.string(r.DecisionHash) - w.string(r.IntentHash) - if r.Version == ReceiptDisclosureVersion { - w.string(r.DisclosureHash) - } - w.string(r.TenantID) - if r.MandateID != "" { - w.string(r.MandateID) - } - w.string(r.AgentID) - w.string(string(r.Outcome)) - w.string(string(r.Result)) - w.string(r.EnforcementPoint) - w.i64(r.ObservedAt) - w.string(r.KeyID) - return w.Bytes(), nil -} - -func (r Receipt) Hash() (string, error) { return hashCanonical(r.Canonical()) } - -func (r *Receipt) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid receipt private key length") - } - return r.SignWith(func(message []byte) ([]byte, error) { - return ed25519.Sign(privateKey, message), nil - }) -} - -func (r *Receipt) SignWith(signer func([]byte) ([]byte, error)) error { - if signer == nil { - return fmt.Errorf("decision: receipt signer is required") - } - canonical, err := r.Canonical() - if err != nil { - return err - } - signature, err := signer(canonical) - if err != nil { - return fmt.Errorf("decision: sign receipt: %w", err) - } - if len(signature) != ed25519.SignatureSize { - return fmt.Errorf("decision: receipt signer returned invalid signature length") - } - r.Signature = base64.StdEncoding.EncodeToString(signature) - return nil -} - -func (r Receipt) Verify(publicKey ed25519.PublicKey) error { - canonical, err := r.Canonical() - if err != nil { - return err - } - return verifySignature("receipt", publicKey, canonical, r.Signature) -} - -func (r Receipt) VerifyFor(intent Intent, result Decision, publicKey ed25519.PublicKey) error { - return r.VerifyForEnforcer(intent, result, intent.AgentID, publicKey) -} - -// VerifyForEnforcer verifies evidence made by a particular enforcement agent. -// Callers resolve publicKey through the receipt-key delegation for that agent, -// while IntentHash and DecisionHash still bind the receipt to the original -// requesting actor and exact authority decision. -func (r Receipt) VerifyForEnforcer(intent Intent, result Decision, enforcementAgentID string, publicKey ed25519.PublicKey) error { - if err := r.Verify(publicKey); err != nil { - return err - } - intentHash, err := intent.Hash() - if err != nil { - return err - } - decisionHash, err := result.Hash() - if err != nil { - return err - } - if r.IntentHash != intentHash || r.DecisionHash != decisionHash || r.DecisionID != result.ID { - return fmt.Errorf("decision: receipt object binding mismatch") - } - if r.TenantID != intent.TenantID || r.MandateID != intent.MandateID || r.AgentID != enforcementAgentID || r.Outcome != result.Outcome { - return fmt.Errorf("decision: receipt authority binding mismatch") - } - return nil -} - -// VerifyForDisclosure proves that V2 evidence was signed for this exact -// disclosure binding in addition to the Intent and Decision it already binds. -func (r Receipt) VerifyForDisclosure(intent Intent, result Decision, disclosure DisclosureBinding, enforcementAgentID string, publicKey ed25519.PublicKey) error { - if r.Version != ReceiptDisclosureVersion { - return fmt.Errorf("decision: disclosure evidence requires a V2 receipt") - } - if err := disclosure.VerifyIntent(intent); err != nil { - return err - } - disclosureHash, err := disclosure.Hash() - if err != nil { - return err - } - if r.DisclosureHash != disclosureHash { - return fmt.Errorf("decision: receipt disclosure binding mismatch") - } - return r.VerifyForEnforcer(intent, result, enforcementAgentID, publicKey) -} diff --git a/decision/receipt_export.go b/decision/receipt_export.go deleted file mode 100644 index ea3dedc..0000000 --- a/decision/receipt_export.go +++ /dev/null @@ -1,229 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "sync" - "time" - - "github.com/pilot-protocol/common/fsutil" -) - -// ReceiptExporterConfig configures asynchronous delivery of already-signed -// enforcement evidence to a customer retention, SIEM, or billing collector. -// It is intentionally independent from Enforcer and cannot allow, deny, or -// delay an action. -type ReceiptExporterConfig struct { - Journal *ReceiptJournal - Endpoint string - AckPath string - BearerToken string - HTTPClient *http.Client - Interval time.Duration - BatchSize int -} - -// ReceiptExporter retries signed evidence until a collector echoes the exact -// receipt ID. Its locally fsynced acknowledgement makes retries idempotent -// across restarts; a collector outage leaves enforcement unaffected. -type ReceiptExporter struct { - journal *ReceiptJournal - endpoint *url.URL - ackPath string - bearerToken string - httpClient *http.Client - interval time.Duration - batchSize int - - mu sync.Mutex - acked map[string]struct{} -} - -func NewReceiptExporter(config ReceiptExporterConfig) (*ReceiptExporter, error) { - if config.Journal == nil || config.Endpoint == "" || config.AckPath == "" { - return nil, fmt.Errorf("decision: receipt journal, endpoint, and acknowledgement path are required") - } - endpoint, err := url.Parse(config.Endpoint) - if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { - return nil, fmt.Errorf("decision: invalid receipt export endpoint") - } - if endpoint.Scheme != "https" && !(endpoint.Scheme == "http" && usageLoopback(endpoint.Hostname())) { - return nil, fmt.Errorf("decision: receipt export endpoint must use HTTPS") - } - ackPath, err := receiptExportAckPath(config.AckPath) - if err != nil { - return nil, err - } - if config.HTTPClient == nil { - config.HTTPClient = &http.Client{Timeout: 10 * time.Second} - } - if config.Interval <= 0 { - config.Interval = 30 * time.Second - } - if config.BatchSize <= 0 { - config.BatchSize = 100 - } - exporter := &ReceiptExporter{ - journal: config.Journal, endpoint: endpoint, ackPath: ackPath, - bearerToken: config.BearerToken, httpClient: config.HTTPClient, - interval: config.Interval, batchSize: config.BatchSize, acked: make(map[string]struct{}), - } - if err := exporter.loadAcks(); err != nil { - return nil, err - } - return exporter, nil -} - -func receiptExportAckPath(path string) (string, error) { - absolute, err := filepath.Abs(filepath.Clean(path)) - if err != nil { - return "", err - } - if info, statErr := os.Lstat(absolute); statErr == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { - return "", fmt.Errorf("decision: receipt export acknowledgement path must be an owner-only regular file") - } - } else if !os.IsNotExist(statErr) { - return "", statErr - } - if err := os.MkdirAll(filepath.Dir(absolute), 0o700); err != nil { - return "", err - } - return absolute, nil -} - -func (exporter *ReceiptExporter) Run(ctx context.Context) error { - ticker := time.NewTicker(exporter.interval) - defer ticker.Stop() - for { - _ = exporter.ExportOnce(ctx) - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - } - } -} - -func (exporter *ReceiptExporter) ExportOnce(ctx context.Context) error { - if exporter == nil || exporter.journal == nil { - return fmt.Errorf("decision: receipt exporter is not initialized") - } - exporter.mu.Lock() - defer exporter.mu.Unlock() - if err := exporter.journal.Refresh(); err != nil { - return fmt.Errorf("decision: refresh receipt journal: %w", err) - } - attempted := 0 - var firstErr error - for _, receipt := range exporter.journal.Receipts() { - if _, exists := exporter.acked[receipt.ID]; exists { - continue - } - if attempted >= exporter.batchSize { - break - } - attempted++ - if err := exporter.send(ctx, receipt); err != nil { - if firstErr == nil { - firstErr = err - } - continue - } - if err := fsutil.AppendSync(exporter.ackPath, []byte(receipt.ID+"\n")); err != nil { - if firstErr == nil { - firstErr = fmt.Errorf("decision: persist receipt export acknowledgement: %w", err) - } - continue - } - exporter.acked[receipt.ID] = struct{}{} - } - return firstErr -} - -func (exporter *ReceiptExporter) Pending() int { - if exporter == nil || exporter.journal == nil { - return 0 - } - exporter.mu.Lock() - defer exporter.mu.Unlock() - pending := 0 - for _, receipt := range exporter.journal.Receipts() { - if _, exists := exporter.acked[receipt.ID]; !exists { - pending++ - } - } - return pending -} - -func (exporter *ReceiptExporter) send(ctx context.Context, receipt Receipt) error { - if err := receipt.Validate(); err != nil || !validJournalSignature(receipt.Signature) { - return fmt.Errorf("decision: invalid signed receipt for export") - } - body, err := json.Marshal(receipt) - if err != nil { - return err - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, exporter.endpoint.String(), bytes.NewReader(body)) - if err != nil { - return err - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json") - request.Header.Set("Idempotency-Key", receipt.ID) - if exporter.bearerToken != "" { - request.Header.Set("Authorization", "Bearer "+exporter.bearerToken) - } - response, err := exporter.httpClient.Do(request) - if err != nil { - return fmt.Errorf("decision: export receipt: %w", err) - } - defer response.Body.Close() - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10)) - return fmt.Errorf("decision: receipt endpoint returned HTTP %d", response.StatusCode) - } - decoder := json.NewDecoder(io.LimitReader(response.Body, 4<<10)) - decoder.DisallowUnknownFields() - var acknowledgement struct { - AcceptedReceiptID string `json:"accepted_receipt_id"` - } - if err := decoder.Decode(&acknowledgement); err != nil || acknowledgement.AcceptedReceiptID != receipt.ID { - return fmt.Errorf("decision: receipt endpoint returned invalid acknowledgement") - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return fmt.Errorf("decision: receipt endpoint returned trailing data") - } - return nil -} - -func (exporter *ReceiptExporter) loadAcks() error { - file, err := os.Open(exporter.ackPath) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return err - } - defer file.Close() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - receiptID := scanner.Text() - if !lowerHex(receiptID, 64) { - return fmt.Errorf("decision: invalid receipt export acknowledgement %q", receiptID) - } - exporter.acked[receiptID] = struct{}{} - } - return scanner.Err() -} diff --git a/decision/receipt_export_test.go b/decision/receipt_export_test.go deleted file mode 100644 index 7514ad3..0000000 --- a/decision/receipt_export_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" -) - -func TestReceiptExporterDiscoversEvidenceAppendedByAnotherProcess(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "receipts.jsonl") - exporterJournal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - writerJournal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - receipt := journalReceipt(t, 1785500000, Enforced) - if err := writerJournal.AppendReceipt(context.Background(), receipt); err != nil { - t.Fatal(err) - } - var calls atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - calls.Add(1) - if request.Header.Get("Idempotency-Key") != receipt.ID { - t.Errorf("idempotency key=%q", request.Header.Get("Idempotency-Key")) - } - _ = json.NewEncoder(writer).Encode(map[string]string{"accepted_receipt_id": receipt.ID}) - })) - defer server.Close() - exporter, err := NewReceiptExporter(ReceiptExporterConfig{ - Journal: exporterJournal, Endpoint: server.URL, AckPath: filepath.Join(t.TempDir(), "acks"), - }) - if err != nil { - t.Fatal(err) - } - if err := exporter.ExportOnce(context.Background()); err != nil { - t.Fatal(err) - } - if calls.Load() != 1 || exporter.Pending() != 0 { - t.Fatalf("external receipt calls=%d pending=%d", calls.Load(), exporter.Pending()) - } -} - -func TestReceiptExporterSurfacesIncompleteExternalJournalRecord(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "receipts.jsonl") - journal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - exporter, err := NewReceiptExporter(ReceiptExporterConfig{ - Journal: journal, Endpoint: "http://127.0.0.1:1/receipts", AckPath: filepath.Join(t.TempDir(), "acks"), - }) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { - t.Fatal(err) - } - if err := exporter.ExportOnce(context.Background()); err == nil || !strings.Contains(err.Error(), "refresh receipt journal") { - t.Fatalf("incomplete external journal error=%v", err) - } -} - -func TestReceiptExporterRetriesAndAcknowledgesSignedEvidence(t *testing.T) { - t.Parallel() - journal, err := OpenReceiptJournal(filepath.Join(t.TempDir(), "receipts.jsonl")) - if err != nil { - t.Fatal(err) - } - receipt := journalReceipt(t, 1785500000, Enforced) - if err := journal.AppendReceipt(context.Background(), receipt); err != nil { - t.Fatal(err) - } - if records := journal.Receipts(); len(records) != 1 || records[0].ID != receipt.ID { - t.Fatalf("journal records = %+v", records) - } - var calls atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - call := calls.Add(1) - if request.Method != http.MethodPost || request.Header.Get("Idempotency-Key") != receipt.ID { - t.Errorf("request method=%s idempotency=%q", request.Method, request.Header.Get("Idempotency-Key")) - } - var received Receipt - if err := json.NewDecoder(request.Body).Decode(&received); err != nil || received.ID != receipt.ID || received.Signature == "" { - t.Errorf("receipt payload=%+v err=%v", received, err) - } - if call == 1 { - writer.WriteHeader(http.StatusServiceUnavailable) - return - } - _ = json.NewEncoder(writer).Encode(map[string]string{"accepted_receipt_id": receipt.ID}) - })) - defer server.Close() - ackPath := filepath.Join(t.TempDir(), "receipt-export.acks") - exporter, err := NewReceiptExporter(ReceiptExporterConfig{Journal: journal, Endpoint: server.URL, AckPath: ackPath}) - if err != nil { - t.Fatal(err) - } - if err := exporter.ExportOnce(context.Background()); err == nil || exporter.Pending() != 1 { - t.Fatalf("failed receipt export err=%v pending=%d", err, exporter.Pending()) - } - if err := exporter.ExportOnce(context.Background()); err != nil || exporter.Pending() != 0 { - t.Fatalf("successful receipt retry err=%v pending=%d", err, exporter.Pending()) - } - restarted, err := NewReceiptExporter(ReceiptExporterConfig{Journal: journal, Endpoint: server.URL, AckPath: ackPath}) - if err != nil { - t.Fatal(err) - } - if err := restarted.ExportOnce(context.Background()); err != nil || calls.Load() != 2 { - t.Fatalf("restart resent acknowledged receipt: calls=%d err=%v", calls.Load(), err) - } -} - -func TestReceiptExporterRejectsInsecureEndpointAndBadAcknowledgement(t *testing.T) { - t.Parallel() - journal, err := OpenReceiptJournal(filepath.Join(t.TempDir(), "receipts.jsonl")) - if err != nil { - t.Fatal(err) - } - receipt := journalReceipt(t, 1785500000, Enforced) - if err := journal.AppendReceipt(context.Background(), receipt); err != nil { - t.Fatal(err) - } - if _, err := NewReceiptExporter(ReceiptExporterConfig{Journal: journal, Endpoint: "http://example.com/receipts", AckPath: filepath.Join(t.TempDir(), "acks")}); err == nil { - t.Fatal("plaintext non-loopback receipt endpoint was accepted") - } - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(writer).Encode(map[string]string{"accepted_receipt_id": "wrong"}) - })) - defer server.Close() - exporter, err := NewReceiptExporter(ReceiptExporterConfig{Journal: journal, Endpoint: server.URL, AckPath: filepath.Join(t.TempDir(), "acks")}) - if err != nil { - t.Fatal(err) - } - if err := exporter.ExportOnce(context.Background()); err == nil || exporter.Pending() != 1 { - t.Fatalf("bad receipt acknowledgement err=%v pending=%d", err, exporter.Pending()) - } -} diff --git a/decision/receipt_journal.go b/decision/receipt_journal.go deleted file mode 100644 index 9a3c475..0000000 --- a/decision/receipt_journal.go +++ /dev/null @@ -1,217 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bufio" - "bytes" - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "sync" - - "github.com/pilot-protocol/common/fsutil" -) - -const MaxReceiptJournalLineBytes = 1 << 20 - -// ReceiptJournal is an append-only, fsync-before-return local evidence sink. -// Receipt IDs make retries idempotent; conflicting content for one ID fails. -type ReceiptJournal struct { - path string - mu sync.Mutex - seen map[string]string - receipts []Receipt - offset int64 -} - -func OpenReceiptJournal(path string) (*ReceiptJournal, error) { - if path == "" { - return nil, fmt.Errorf("decision: receipt journal path is required") - } - absolute, err := filepath.Abs(filepath.Clean(path)) - if err != nil { - return nil, fmt.Errorf("decision: resolve receipt journal: %w", err) - } - if info, statErr := os.Lstat(absolute); statErr == nil && info.Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("decision: receipt journal must not be a symlink") - } else if statErr != nil && !os.IsNotExist(statErr) { - return nil, fmt.Errorf("decision: inspect receipt journal: %w", statErr) - } - if err := os.MkdirAll(filepath.Dir(absolute), 0o700); err != nil { - return nil, fmt.Errorf("decision: create receipt journal directory: %w", err) - } - journal := &ReceiptJournal{path: absolute, seen: make(map[string]string)} - if err := journal.refreshLocked(); err != nil { - return nil, err - } - return journal, nil -} - -func (journal *ReceiptJournal) AppendReceipt(ctx context.Context, receipt Receipt) error { - if journal == nil || journal.path == "" { - return fmt.Errorf("decision: receipt journal is not initialized") - } - if err := ctx.Err(); err != nil { - return err - } - if err := receipt.Validate(); err != nil { - return err - } - if !validJournalSignature(receipt.Signature) { - return fmt.Errorf("decision: receipt journal requires a signed receipt") - } - hash, err := receipt.Hash() - if err != nil { - return err - } - body, err := json.Marshal(receipt) - if err != nil { - return fmt.Errorf("decision: encode receipt: %w", err) - } - if len(body) > MaxReceiptJournalLineBytes { - return fmt.Errorf("decision: receipt exceeds journal line limit") - } - journal.mu.Lock() - defer journal.mu.Unlock() - if existing, exists := journal.seen[receipt.ID]; exists { - if existing != hash { - return fmt.Errorf("decision: conflicting receipt for id %q", receipt.ID) - } - return nil - } - if err := ctx.Err(); err != nil { - return err - } - if err := fsutil.AppendSync(journal.path, append(body, '\n')); err != nil { - return fmt.Errorf("decision: append receipt journal: %w", err) - } - journal.seen[receipt.ID] = hash - journal.receipts = append(journal.receipts, receipt) - return nil -} - -// Receipts returns a stable snapshot in journal append order. Exporters use -// this read-only view outside authorization and enforcement paths; the journal -// itself remains the durable idempotency authority. -func (journal *ReceiptJournal) Receipts() []Receipt { - if journal == nil { - return nil - } - journal.mu.Lock() - defer journal.mu.Unlock() - return append([]Receipt(nil), journal.receipts...) -} - -// Refresh discovers complete records appended by another process after this -// journal was opened. Managed hooks and the long-running daemon deliberately -// use separate processes, so exporters must refresh before inspecting their -// in-memory snapshot. Existing records are not rescanned on every poll. -func (journal *ReceiptJournal) Refresh() error { - if journal == nil || journal.path == "" { - return fmt.Errorf("decision: receipt journal is not initialized") - } - journal.mu.Lock() - defer journal.mu.Unlock() - return journal.refreshLocked() -} - -func (journal *ReceiptJournal) refreshLocked() error { - pathInfo, err := os.Lstat(journal.path) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("decision: inspect receipt journal: %w", err) - } - if pathInfo.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("decision: receipt journal must not be a symlink") - } - file, err := os.Open(journal.path) - if err != nil { - return fmt.Errorf("decision: open receipt journal: %w", err) - } - defer file.Close() - info, err := file.Stat() - if err != nil || !info.Mode().IsRegular() { - return fmt.Errorf("decision: receipt journal must be a regular file") - } - if info.Mode().Perm()&0o077 != 0 { - return fmt.Errorf("decision: receipt journal permissions must be owner-only") - } - if info.Size() < journal.offset { - return fmt.Errorf("decision: receipt journal was truncated") - } - if info.Size() == journal.offset { - return nil - } - var last [1]byte - if _, err := file.ReadAt(last[:], info.Size()-1); err != nil { - return fmt.Errorf("decision: inspect receipt journal boundary: %w", err) - } - if last[0] != '\n' { - return fmt.Errorf("decision: receipt journal has an incomplete trailing record") - } - scanner := bufio.NewScanner(io.NewSectionReader(file, journal.offset, info.Size()-journal.offset)) - scanner.Buffer(make([]byte, 64<<10), MaxReceiptJournalLineBytes+1) - line := len(journal.receipts) - type pendingReceipt struct { - receipt Receipt - hash string - } - pending := make([]pendingReceipt, 0) - pendingSeen := make(map[string]string) - for scanner.Scan() { - line++ - decoder := json.NewDecoder(bytes.NewReader(scanner.Bytes())) - decoder.DisallowUnknownFields() - var receipt Receipt - if err := decoder.Decode(&receipt); err != nil { - return fmt.Errorf("decision: decode receipt journal line %d: %w", line, err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return fmt.Errorf("decision: receipt journal line %d has trailing JSON", line) - } - if err := receipt.Validate(); err != nil { - return fmt.Errorf("decision: validate receipt journal line %d: %w", line, err) - } - if !validJournalSignature(receipt.Signature) { - return fmt.Errorf("decision: receipt journal line %d is unsigned", line) - } - hash, _ := receipt.Hash() - if existing, exists := journal.seen[receipt.ID]; exists && existing != hash { - return fmt.Errorf("decision: conflicting receipt journal id %q", receipt.ID) - } - if existing, exists := pendingSeen[receipt.ID]; exists && existing != hash { - return fmt.Errorf("decision: conflicting receipt journal id %q", receipt.ID) - } - if existing, exists := journal.seen[receipt.ID]; exists && existing == hash { - continue - } - if existing, exists := pendingSeen[receipt.ID]; exists && existing == hash { - continue - } - pendingSeen[receipt.ID] = hash - pending = append(pending, pendingReceipt{receipt: receipt, hash: hash}) - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("decision: scan receipt journal: %w", err) - } - for _, record := range pending { - journal.seen[record.receipt.ID] = record.hash - journal.receipts = append(journal.receipts, record.receipt) - } - journal.offset = info.Size() - return nil -} - -func validJournalSignature(encoded string) bool { - signature, err := base64.StdEncoding.DecodeString(encoded) - return err == nil && len(signature) == ed25519.SignatureSize -} diff --git a/decision/receipt_journal_test.go b/decision/receipt_journal_test.go deleted file mode 100644 index f9d09a7..0000000 --- a/decision/receipt_journal_test.go +++ /dev/null @@ -1,202 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/pilot-protocol/common/fsutil" -) - -func journalReceipt(t *testing.T, observedAt int64, result EnforcementResult) Receipt { - t.Helper() - _, privateKey, _ := ed25519.GenerateKey(rand.Reader) - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - intentHash, _ := intent.Hash() - decisionResult := Decision{ - Version: SchemaVersion, ID: "journal-decision", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: Allow, - ProviderID: "journal", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-1", - } - receipt, err := NewReceipt(intent, decisionResult, "wallet-journal", "receipt-key-1", observedAt, result) - if err != nil { - t.Fatal(err) - } - if err := receipt.Sign(privateKey); err != nil { - t.Fatal(err) - } - return receipt -} - -func TestReceiptJournalIsDurableIdempotentAndConflictSafe(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "receipts.jsonl") - journal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - receipt := journalReceipt(t, 1785500000, Enforced) - if err := journal.AppendReceipt(context.Background(), receipt); err != nil { - t.Fatal(err) - } - if err := journal.AppendReceipt(context.Background(), receipt); err != nil { - t.Fatalf("idempotent append: %v", err) - } - reopened, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - if err := reopened.AppendReceipt(context.Background(), receipt); err != nil { - t.Fatalf("restart idempotency: %v", err) - } - conflict := receipt - conflict.ObservedAt++ - _, privateKey, _ := ed25519.GenerateKey(rand.Reader) - _ = conflict.Sign(privateKey) - if err := reopened.AppendReceipt(context.Background(), conflict); err == nil { - t.Fatal("conflicting receipt ID was accepted") - } - body, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - lines := 0 - for _, character := range body { - if character == '\n' { - lines++ - } - } - if lines != 1 { - t.Fatalf("journal lines=%d, want 1", lines) - } -} - -func TestReceiptJournalRejectsUnsignedCorruptAndUnsafeFiles(t *testing.T) { - t.Parallel() - directory := t.TempDir() - path := filepath.Join(directory, "receipts.jsonl") - journal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - unsigned := journalReceipt(t, 1785500000, Enforced) - unsigned.Signature = "" - if err := journal.AppendReceipt(context.Background(), unsigned); err == nil { - t.Fatal("unsigned receipt was accepted") - } - unsafe := filepath.Join(directory, "unsafe.jsonl") - if err := os.WriteFile(unsafe, []byte("not-json\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := OpenReceiptJournal(unsafe); err == nil { - t.Fatal("unsafe/corrupt journal was accepted") - } - realPath := filepath.Join(directory, "real.jsonl") - if err := os.WriteFile(realPath, nil, 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(realPath, filepath.Join(directory, "link.jsonl")); err != nil { - t.Fatal(err) - } - if _, err := OpenReceiptJournal(filepath.Join(directory, "link.jsonl")); err == nil { - t.Fatal("symlink journal was accepted") - } -} - -func TestReceiptJournalRefreshRejectsUnsafeExternalChanges(t *testing.T) { - t.Parallel() - if err := (*ReceiptJournal)(nil).Refresh(); err == nil { - t.Fatal("nil journal refresh succeeded") - } - t.Run("incomplete record", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "receipts.jsonl") - journal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { - t.Fatal(err) - } - if err := journal.Refresh(); err == nil || !strings.Contains(err.Error(), "incomplete trailing record") { - t.Fatalf("incomplete refresh error=%v", err) - } - }) - t.Run("truncated journal", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "receipts.jsonl") - journal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - if err := journal.AppendReceipt(context.Background(), journalReceipt(t, 1785500000, Enforced)); err != nil { - t.Fatal(err) - } - if err := journal.Refresh(); err != nil { - t.Fatal(err) - } - if err := os.Truncate(path, 0); err != nil { - t.Fatal(err) - } - if err := journal.Refresh(); err == nil || !strings.Contains(err.Error(), "truncated") { - t.Fatalf("truncated refresh error=%v", err) - } - }) - t.Run("symlink replacement", func(t *testing.T) { - directory := t.TempDir() - path := filepath.Join(directory, "receipts.jsonl") - journal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - target := filepath.Join(directory, "target.jsonl") - if err := os.WriteFile(target, nil, 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(target, path); err != nil { - t.Fatal(err) - } - if err := journal.Refresh(); err == nil || !strings.Contains(err.Error(), "symlink") { - t.Fatalf("symlink refresh error=%v", err) - } - }) -} - -func TestReceiptJournalRefreshRejectsConflictingExternalRecord(t *testing.T) { - t.Parallel() - path := filepath.Join(t.TempDir(), "receipts.jsonl") - journal, err := OpenReceiptJournal(path) - if err != nil { - t.Fatal(err) - } - receipt := journalReceipt(t, 1785500000, Enforced) - if err := journal.AppendReceipt(context.Background(), receipt); err != nil { - t.Fatal(err) - } - if err := journal.Refresh(); err != nil { - t.Fatal(err) - } - conflict := receipt - conflict.ObservedAt++ - _, privateKey, _ := ed25519.GenerateKey(rand.Reader) - if err := conflict.Sign(privateKey); err != nil { - t.Fatal(err) - } - body, err := json.Marshal(conflict) - if err != nil { - t.Fatal(err) - } - if err := fsutil.AppendSync(path, append(body, '\n')); err != nil { - t.Fatal(err) - } - if err := journal.Refresh(); err == nil || !strings.Contains(err.Error(), "conflicting receipt journal id") { - t.Fatalf("conflicting refresh error=%v", err) - } -} diff --git a/decision/receipt_syslog.go b/decision/receipt_syslog.go deleted file mode 100644 index f099a72..0000000 --- a/decision/receipt_syslog.go +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "fmt" - "strings" - "time" -) - -// ReceiptRFC5424 formats signed enforcement evidence as an RFC 5424 syslog -// event. It deliberately carries receipt metadata and signature only; raw -// application payloads are represented by the receipt's signed hashes. -func ReceiptRFC5424(receipt Receipt) (string, error) { - if err := receipt.Validate(); err != nil { - return "", err - } - fields := []struct{ key, value string }{ - {"receipt_id", receipt.ID}, {"tenant_id", receipt.TenantID}, {"agent_id", receipt.AgentID}, - {"decision_id", receipt.DecisionID}, {"decision_hash", receipt.DecisionHash}, {"intent_hash", receipt.IntentHash}, - {"mandate_id", receipt.MandateID}, {"outcome", string(receipt.Outcome)}, {"result", string(receipt.Result)}, - {"enforcement_point", receipt.EnforcementPoint}, {"key_id", receipt.KeyID}, {"signature", receipt.Signature}, - } - structured := make([]string, 0, len(fields)) - for _, field := range fields { - if field.value != "" { - structured = append(structured, field.key+`="`+rfc5424Escape(field.value)+`"`) - } - } - // local0.info, RFC 5424 version 1. Timestamp reflects the signed - // observation time rather than collector arrival time. - return fmt.Sprintf("<134>1 %s - pilot - PILOT_RECEIPT [pilot@32473 %s] signed enforcement receipt", time.Unix(receipt.ObservedAt, 0).UTC().Format(time.RFC3339), strings.Join(structured, " ")), nil -} - -func rfc5424Escape(value string) string { - return strings.NewReplacer(`\`, `\\`, `"`, `\"`, `]`, `\]`).Replace(value) -} diff --git a/decision/receipt_syslog_test.go b/decision/receipt_syslog_test.go deleted file mode 100644 index d08472f..0000000 --- a/decision/receipt_syslog_test.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "strings" - "testing" -) - -func TestReceiptRFC5424CarriesSignedEvidenceWithoutPayload(t *testing.T) { - receipt := journalReceipt(t, 1785500000, Enforced) - line, err := ReceiptRFC5424(receipt) - if err != nil { - t.Fatal(err) - } - for _, expected := range []string{ - "<134>1 2026-07-31T", "PILOT_RECEIPT", `receipt_id="` + receipt.ID + `"`, - `tenant_id="` + receipt.TenantID + `"`, `decision_hash="` + receipt.DecisionHash + `"`, - `signature="` + receipt.Signature + `"`, "signed enforcement receipt", - } { - if !strings.Contains(line, expected) { - t.Fatalf("RFC5424 receipt missing %q: %s", expected, line) - } - } - if strings.Contains(line, "payload") { - t.Fatalf("RFC5424 receipt unexpectedly contains payload field: %s", line) - } -} - -func TestRFC5424Escape(t *testing.T) { - if got, want := rfc5424Escape(`a\\b"c]`), `a\\\\b\"c\]`; got != want { - t.Fatalf("escape=%q want=%q", got, want) - } -} diff --git a/decision/receipt_test.go b/decision/receipt_test.go deleted file mode 100644 index f3541c8..0000000 --- a/decision/receipt_test.go +++ /dev/null @@ -1,196 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bytes" - "crypto/ed25519" - "crypto/rand" - "encoding/json" - "io" - "strings" - "testing" - "time" -) - -func TestReceiptIsSignedBoundAndIdempotent(t *testing.T) { - t.Parallel() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - intentHash, _ := intent.Hash() - result := Decision{ - Version: SchemaVersion, ID: "decision-receipt", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: Deny, - PolicyRevision: 9, RevocationEpoch: 4, ProviderID: "managed", - IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-1", - } - receipt, err := NewReceipt(intent, result, "daemon-node-7", "receipt-key-1", now.Unix(), Denied) - if err != nil { - t.Fatal(err) - } - if err := receipt.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := receipt.VerifyFor(intent, result, publicKey); err != nil { - t.Fatalf("valid receipt rejected: %v", err) - } - second, err := NewReceipt(intent, result, "daemon-node-7", "receipt-key-1", now.Add(time.Second).Unix(), Denied) - if err != nil { - t.Fatal(err) - } - if receipt.UsageUnitID() != second.UsageUnitID() { - t.Fatal("retry generated a second billable usage identifier") - } - tampered := receipt - tampered.Result = Enforced - if err := tampered.Verify(publicKey); err == nil { - t.Fatalf("tampered receipt error = %v", err) - } -} - -func TestReceiptRejectsNonCanonicalIDAndCrossDecisionReplay(t *testing.T) { - t.Parallel() - publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader) - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - intentHash, _ := intent.Hash() - result := Decision{ - Version: SchemaVersion, ID: "decision-a", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: Allow, - ProviderID: "local", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-1", - } - receipt, err := NewReceipt(intent, result, "wallet-1", "receipt-key", now.Unix(), Enforced) - if err != nil { - t.Fatal(err) - } - if err := receipt.Sign(privateKey); err != nil { - t.Fatal(err) - } - badID := receipt - badID.ID = strings.Repeat("a", 64) - if err := badID.Validate(); err == nil || !strings.Contains(err.Error(), "canonical") { - t.Fatalf("noncanonical receipt id error = %v", err) - } - other := result - other.ID = "decision-b" - if err := receipt.VerifyFor(intent, other, publicKey); err == nil { - t.Fatal("receipt replayed across decisions") - } -} - -func TestReceiptCanBeAttributedToASeparateEnforcementAgent(t *testing.T) { - t.Parallel() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0) - intent := testIntent(t, now) - intentHash, _ := intent.Hash() - result := Decision{ - Version: SchemaVersion, ID: "decision-receiver-receipt", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: Allow, - ProviderID: "managed", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-1", - } - receipt, err := NewReceiptForEnforcer(intent, result, "receiver-a", "dataexchange", "receiver-receipt-key", now.Unix(), Enforced) - if err != nil { - t.Fatal(err) - } - if receipt.AgentID != "receiver-a" { - t.Fatalf("receipt enforcement agent = %q", receipt.AgentID) - } - if err := receipt.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := receipt.VerifyForEnforcer(intent, result, "receiver-a", publicKey); err != nil { - t.Fatalf("receiver receipt rejected: %v", err) - } - if err := receipt.VerifyFor(intent, result, publicKey); err == nil { - t.Fatal("receiver receipt verified as if it were signed by the sender") - } -} - -func TestDisclosureReceiptV2BindsCanonicalDisclosureWithoutChangingV1(t *testing.T) { - t.Parallel() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1785500000, 0) - disclosure := DisclosureBinding{ - Version: DisclosureBindingVersion, ContentHash: HashPayload([]byte("invoice")), DeclaredBytes: 7, - ContentType: "application/pdf", Labels: []string{"finance", "pii"}, Recipient: "agent:finance", - Purpose: "invoice-payment", Residency: "eu-west-1", Filename: "invoice.pdf", - } - disclosureHash, err := disclosure.Hash() - if err != nil { - t.Fatal(err) - } - intent := testIntent(t, now) - intent.Audience, intent.Purpose, intent.PayloadHash = disclosure.Recipient, disclosure.Purpose, disclosureHash - intentHash, err := intent.Hash() - if err != nil { - t.Fatal(err) - } - result := Decision{ - Version: SchemaVersion, ID: "decision-disclosure-receipt", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, - Outcome: Allow, PolicyRevision: 9, RevocationEpoch: 4, ProviderID: "managed", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-1", - } - receipt, err := NewDisclosureReceiptForEnforcer(intent, result, disclosure, "receiver-a", "dataexchange", "receipt-key", now.Unix(), Enforced) - if err != nil { - t.Fatal(err) - } - if receipt.Version != ReceiptDisclosureVersion || receipt.DisclosureHash != disclosureHash { - t.Fatalf("receipt=%+v", receipt) - } - if err := receipt.Sign(privateKey); err != nil { - t.Fatal(err) - } - if err := receipt.VerifyForDisclosure(intent, result, disclosure, "receiver-a", publicKey); err != nil { - t.Fatalf("V2 disclosure receipt rejected: %v", err) - } - mutated := disclosure - mutated.Residency = "us-east-1" - if err := receipt.VerifyForDisclosure(intent, result, mutated, "receiver-a", publicKey); err == nil { - t.Fatal("receipt verified for a mutated disclosure") - } - v1 := receipt - v1.Version, v1.DisclosureHash = SchemaVersion, "" - if err := v1.Validate(); err != nil { - t.Fatalf("converted V1 fields invalid: %v", err) - } - if err := v1.Verify(publicKey); err == nil { - t.Fatal("V2 signature was accepted with V1 canonical bytes") - } -} - -func FuzzReceiptCanonicalization(f *testing.F) { - f.Add([]byte(`{"version":1}`)) - f.Add([]byte(`{"version":1,"id":"0000000000000000000000000000000000000000000000000000000000000000","decision_id":"decision-a","decision_hash":"0000000000000000000000000000000000000000000000000000000000000000","intent_hash":"0000000000000000000000000000000000000000000000000000000000000000","tenant_id":"tenant-a","agent_id":"agent-a","outcome":"deny","result":"denied","enforcement_point":"wallet","observed_at":1,"key_id":"receipt-a"}`)) - f.Fuzz(func(t *testing.T, body []byte) { - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - var receipt Receipt - if err := decoder.Decode(&receipt); err != nil { - return - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return - } - if err := receipt.Validate(); err != nil { - return - } - canonical, err := receipt.Canonical() - if err != nil || len(canonical) == 0 { - t.Fatalf("valid receipt canonicalization failed: bytes=%d err=%v", len(canonical), err) - } - if _, err := receipt.Hash(); err != nil { - t.Fatalf("valid receipt hash failed: %v", err) - } - }) -} diff --git a/decision/semantic_policy.go b/decision/semantic_policy.go deleted file mode 100644 index cd73f35..0000000 --- a/decision/semantic_policy.go +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "strings" -) - -const SemanticPolicyContextVersion uint16 = 1 - -// SemanticPolicyClause is reviewed policy input for an optional semantic -// evaluator. It may only describe a narrowing result. The evaluator still -// runs below deterministic policy and cannot grant authority. -type SemanticPolicyClause struct { - ID string `json:"id"` - StatementID string `json:"statement_id"` - StatementRevision uint64 `json:"statement_revision"` - Instruction string `json:"instruction"` - Actions []string `json:"actions"` - OutcomeOnMatch Outcome `json:"outcome_on_match"` - ApprovalPlanID string `json:"approval_plan_id,omitempty"` - ApprovalPlanRevision uint64 `json:"approval_plan_revision,omitempty"` - MetadataFields []string `json:"metadata_fields"` - FailureMode EvaluationFailureMode `json:"failure_mode"` - ExpiresAt int64 `json:"expires_at,omitempty"` -} - -// SemanticPolicyContext is generated by the authority from active reviewed -// statements. Workload callers cannot supply or replace it on the primary -// authorization endpoint. -type SemanticPolicyContext struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - Clauses []SemanticPolicyClause `json:"clauses"` - ContextHash string `json:"context_hash"` -} - -type SemanticPolicyContextProvider interface { - SemanticPolicyContext(context.Context, Intent) (SemanticPolicyContext, bool, error) -} - -type SemanticContextAuthorizer interface { - AuthorizeSemantic(context.Context, Intent, SemanticPolicyContext) (Decision, error) -} - -type SemanticContextDisclosureAuthorizer interface { - AuthorizeSemanticDisclosure(context.Context, Intent, DisclosureBinding, SemanticPolicyContext) (Decision, error) -} - -// SemanticContextFederatedContentAuthorizer is the Pilot-hosted semantic -// extension. Workload callers cannot submit SemanticPolicyContext on the -// public authorization route; the authority supplies reviewed active clauses. -type SemanticContextFederatedContentAuthorizer interface { - AuthorizeSemanticFederatedContent(context.Context, Intent, FederatedContent, SemanticPolicyContext) (Decision, error) -} - -func NewSemanticPolicyContext(tenantID string, clauses []SemanticPolicyClause) (SemanticPolicyContext, error) { - context := SemanticPolicyContext{Version: SemanticPolicyContextVersion, TenantID: strings.TrimSpace(tenantID), Clauses: cloneSemanticClauses(clauses)} - hash, err := context.Hash() - if err != nil { - return SemanticPolicyContext{}, err - } - context.ContextHash = hash - if err := context.Validate(); err != nil { - return SemanticPolicyContext{}, err - } - return context, nil -} - -func (policy SemanticPolicyContext) Validate() error { - if policy.Version != SemanticPolicyContextVersion || validateIdentifier("semantic tenant_id", policy.TenantID) != nil || len(policy.Clauses) == 0 || len(policy.Clauses) > 64 || !lowerHex(policy.ContextHash, 64) { - return fmt.Errorf("decision: invalid semantic policy context") - } - seen := make(map[string]struct{}, len(policy.Clauses)) - for _, clause := range policy.Clauses { - if err := clause.Validate(); err != nil { - return err - } - if _, duplicate := seen[clause.ID]; duplicate { - return fmt.Errorf("decision: duplicate semantic clause %q", clause.ID) - } - seen[clause.ID] = struct{}{} - } - hash, err := policy.Hash() - if err != nil || hash != policy.ContextHash { - return fmt.Errorf("decision: semantic policy context hash mismatch") - } - return nil -} - -func (policy SemanticPolicyContext) ValidateIntent(intent Intent, now int64) error { - if err := policy.Validate(); err != nil { - return err - } - if policy.TenantID != intent.TenantID { - return fmt.Errorf("decision: semantic policy tenant mismatch") - } - matched := false - for _, clause := range policy.Clauses { - if clause.ExpiresAt > 0 && clause.ExpiresAt <= now { - return fmt.Errorf("decision: semantic policy clause is expired") - } - for _, action := range clause.Actions { - if action == intent.Action { - matched = true - break - } - } - } - if !matched { - return fmt.Errorf("decision: semantic policy has no clause for action %q", intent.Action) - } - return nil -} - -func (policy SemanticPolicyContext) Hash() (string, error) { - copy := policy - copy.ContextHash = "" - payload, err := json.Marshal(copy) - if err != nil { - return "", err - } - sum := sha256.Sum256(payload) - return hex.EncodeToString(sum[:]), nil -} - -func (clause SemanticPolicyClause) Validate() error { - for name, value := range map[string]string{"semantic clause id": clause.ID, "semantic statement id": clause.StatementID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if clause.StatementRevision == 0 || strings.TrimSpace(clause.Instruction) == "" || len(clause.Instruction) > 2048 || strings.ContainsRune(clause.Instruction, 0) || len(clause.Actions) == 0 || len(clause.Actions) > 64 { - return fmt.Errorf("decision: invalid semantic clause content") - } - actions := make(map[string]struct{}, len(clause.Actions)) - for _, action := range clause.Actions { - if !validAction(action) { - return fmt.Errorf("decision: invalid semantic clause action %q", action) - } - if _, duplicate := actions[action]; duplicate { - return fmt.Errorf("decision: duplicate semantic clause action") - } - actions[action] = struct{}{} - } - if clause.OutcomeOnMatch != Deny && clause.OutcomeOnMatch != ApprovalRequired { - return fmt.Errorf("decision: semantic clause may only deny or require approval") - } - if clause.OutcomeOnMatch == ApprovalRequired { - if err := validateIdentifier("semantic approval plan", clause.ApprovalPlanID); err != nil || clause.ApprovalPlanRevision == 0 { - if err != nil { - return err - } - return fmt.Errorf("decision: semantic approval clause requires a plan revision") - } - } else if clause.ApprovalPlanID != "" || clause.ApprovalPlanRevision != 0 { - return fmt.Errorf("decision: semantic deny clause cannot name approval plan") - } - if clause.FailureMode != EvaluationFailureOpen && clause.FailureMode != EvaluationFailureClosed { - return fmt.Errorf("decision: semantic clause requires explicit failure mode") - } - if clause.ExpiresAt < 0 || len(clause.MetadataFields) == 0 || len(clause.MetadataFields) > 16 { - return fmt.Errorf("decision: invalid semantic clause metadata") - } - fields := make(map[string]struct{}, len(clause.MetadataFields)) - for _, field := range clause.MetadataFields { - if err := validateIdentifier("semantic metadata field", field); err != nil { - return err - } - if _, duplicate := fields[field]; duplicate { - return fmt.Errorf("decision: duplicate semantic metadata field") - } - fields[field] = struct{}{} - } - return nil -} - -func cloneSemanticClauses(clauses []SemanticPolicyClause) []SemanticPolicyClause { - result := make([]SemanticPolicyClause, len(clauses)) - copy(result, clauses) - for index := range result { - result[index].Actions = append([]string(nil), result[index].Actions...) - result[index].MetadataFields = append([]string(nil), result[index].MetadataFields...) - } - return result -} diff --git a/decision/transfer_quota.go b/decision/transfer_quota.go deleted file mode 100644 index f51089f..0000000 --- a/decision/transfer_quota.go +++ /dev/null @@ -1,102 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "fmt" - "sync" - "time" -) - -// TransferQuotaConfig bounds admitted governed transfers for each signed -// sender identity within a fixed window. It is an enforcement-plane control: -// only a transport that has already verified an Intent may consume it. -// -// A quota counts admitted attempts, including ones later rejected by a local -// content inspector or receipt recorder. This deliberately prevents a sender -// from using repeated failing deliveries to exhaust local inspection work -// without consuming its own governed-transfer budget. -type TransferQuotaConfig struct { - Window time.Duration - MaxBytes uint64 - MaxActions uint64 - MaxSenders int - Now func() time.Time -} - -// TransferQuotaLimiter is a bounded, concurrency-safe per-sender admission -// limiter. It is local state by design. A future shared/durable limiter must -// preserve these same no-reset-on-clock-rollback semantics. -type TransferQuotaLimiter struct { - window time.Duration - maxBytes uint64 - maxActions uint64 - maxSenders int - now func() time.Time - - mu sync.Mutex - activeWindow time.Time - used map[string]transferQuotaUsage -} - -type transferQuotaUsage struct { - bytes uint64 - actions uint64 -} - -// NewTransferQuotaLimiter validates and initializes a local quota limiter. -func NewTransferQuotaLimiter(config TransferQuotaConfig) (*TransferQuotaLimiter, error) { - if config.Window < time.Second || config.Window > time.Hour { - return nil, fmt.Errorf("decision: transfer quota window must be 1s-1h") - } - if config.MaxBytes == 0 && config.MaxActions == 0 { - return nil, fmt.Errorf("decision: transfer quota needs a byte or action limit") - } - if config.MaxSenders < 1 || config.MaxSenders > 10000 { - return nil, fmt.Errorf("decision: transfer quota max senders must be 1-10000") - } - now := config.Now - if now == nil { - now = time.Now - } - return &TransferQuotaLimiter{ - window: config.Window, maxBytes: config.MaxBytes, maxActions: config.MaxActions, maxSenders: config.MaxSenders, - now: now, used: make(map[string]transferQuotaUsage), - }, nil -} - -// Allow reserves one admitted transfer for sender. An error means the caller -// must deny the delivery before any side effect. sender must be a verified -// signed agent identity, not a transport address or caller-supplied label. -func (limiter *TransferQuotaLimiter) Allow(sender string, bytes uint64) error { - if limiter == nil { - return nil - } - if sender == "" { - return fmt.Errorf("decision: transfer quota sender is required") - } - limiter.mu.Lock() - defer limiter.mu.Unlock() - - window := limiter.now().UTC().Truncate(limiter.window) - if limiter.activeWindow.IsZero() || window.After(limiter.activeWindow) { - limiter.activeWindow = window - limiter.used = make(map[string]transferQuotaUsage) - } - // If local clock moves backward, retain the newer active bucket instead of - // allowing a rollback to reset an already consumed quota. - usage, exists := limiter.used[sender] - if !exists && len(limiter.used) >= limiter.maxSenders { - return fmt.Errorf("decision: transfer quota sender capacity exceeded") - } - if limiter.maxActions != 0 && usage.actions >= limiter.maxActions { - return fmt.Errorf("decision: transfer quota action limit exceeded") - } - if limiter.maxBytes != 0 && (bytes > limiter.maxBytes || usage.bytes > limiter.maxBytes-bytes) { - return fmt.Errorf("decision: transfer quota byte limit exceeded") - } - usage.actions++ - usage.bytes += bytes - limiter.used[sender] = usage - return nil -} diff --git a/decision/transfer_quota_test.go b/decision/transfer_quota_test.go deleted file mode 100644 index 23e146a..0000000 --- a/decision/transfer_quota_test.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "strings" - "testing" - "time" -) - -func TestTransferQuotaLimiterBoundsVerifiedSendersAndResetsOnlyForward(t *testing.T) { - now := time.Date(2026, 8, 1, 12, 0, 10, 0, time.UTC) - limiter, err := NewTransferQuotaLimiter(TransferQuotaConfig{ - Window: time.Minute, MaxBytes: 10, MaxActions: 2, MaxSenders: 2, Now: func() time.Time { return now }, - }) - if err != nil { - t.Fatal(err) - } - if err := limiter.Allow("sender-a", 6); err != nil { - t.Fatal(err) - } - if err := limiter.Allow("sender-a", 5); err == nil || !strings.Contains(err.Error(), "byte limit") { - t.Fatalf("byte limit error=%v", err) - } - if err := limiter.Allow("sender-a", 4); err != nil { - t.Fatal(err) - } - if err := limiter.Allow("sender-a", 0); err == nil || !strings.Contains(err.Error(), "action limit") { - t.Fatalf("action limit error=%v", err) - } - if err := limiter.Allow("sender-b", 1); err != nil { - t.Fatal(err) - } - if err := limiter.Allow("sender-c", 1); err == nil || !strings.Contains(err.Error(), "sender capacity") { - t.Fatalf("sender capacity error=%v", err) - } - - now = now.Add(-time.Minute) - if err := limiter.Allow("sender-a", 1); err == nil || !strings.Contains(err.Error(), "action limit") { - t.Fatalf("clock rollback reset quota: %v", err) - } - now = now.Add(2 * time.Minute) - if err := limiter.Allow("sender-c", 10); err != nil { - t.Fatalf("forward window did not reset quota: %v", err) - } -} - -func TestTransferQuotaLimiterValidatesConfiguration(t *testing.T) { - for _, config := range []TransferQuotaConfig{ - {}, - {Window: time.Second, MaxSenders: 1}, - {Window: 500 * time.Millisecond, MaxBytes: 1, MaxSenders: 1}, - {Window: time.Second, MaxBytes: 1, MaxSenders: 10001}, - } { - if _, err := NewTransferQuotaLimiter(config); err == nil { - t.Fatalf("invalid config accepted: %+v", config) - } - } -} diff --git a/decision/usage_export.go b/decision/usage_export.go deleted file mode 100644 index ee4b5a7..0000000 --- a/decision/usage_export.go +++ /dev/null @@ -1,431 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/pilot-protocol/common/fsutil" -) - -const UsageKindSemanticEvaluation = "semantic_evaluation" - -const ( - UsageExportFailureTransport = "transport" - UsageExportFailureRemoteRejected = "remote_rejected" - UsageExportFailureInvalidAcknowledged = "invalid_acknowledgement" - UsageExportFailureAckPersistence = "ack_persistence" -) - -type UsageEvent struct { - Version uint16 `json:"version"` - UnitID string `json:"unit_id"` - Kind string `json:"kind"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Quantity uint64 `json:"quantity"` - OccurredAt int64 `json:"occurred_at_unix_nano"` - Evaluator EvaluatorIdentity `json:"evaluator"` - Mode EvaluationMode `json:"mode"` - Applied bool `json:"applied"` - BaseOutcome Outcome `json:"base_outcome"` - SemanticOutcome Outcome `json:"semantic_outcome,omitempty"` - AppliedOutcome Outcome `json:"applied_outcome"` - ModelCalls uint64 `json:"model_calls,omitempty"` - InputTokens uint64 `json:"input_tokens,omitempty"` - OutputTokens uint64 `json:"output_tokens,omitempty"` -} - -func (record EvaluationRecord) UsageEvent() UsageEvent { - return UsageEvent{ - Version: SchemaVersion, UnitID: record.UsageUnitID(), Kind: UsageKindSemanticEvaluation, - TenantID: record.TenantID, AgentID: record.AgentID, Quantity: 1, - OccurredAt: record.CompletedAt, Evaluator: record.Identity, Mode: record.Mode, - Applied: record.Applied, BaseOutcome: record.BaseOutcome, - SemanticOutcome: record.SemanticOutcome, AppliedOutcome: record.AppliedOutcome, - ModelCalls: record.ModelCalls, InputTokens: record.InputTokens, OutputTokens: record.OutputTokens, - } -} - -func (event UsageEvent) Validate() error { - if event.Version != SchemaVersion || !lowerHex(event.UnitID, 64) || event.Kind != UsageKindSemanticEvaluation || event.Quantity != 1 || event.OccurredAt <= 0 { - return fmt.Errorf("decision: invalid usage event") - } - record := EvaluationRecord{ - Version: SchemaVersion, ID: event.UnitID, IntentHash: strings.Repeat("0", 64), - TenantID: event.TenantID, AgentID: event.AgentID, Mode: event.Mode, - Identity: event.Evaluator, BaseOutcome: event.BaseOutcome, - SemanticOutcome: event.SemanticOutcome, AppliedOutcome: event.AppliedOutcome, - Applied: event.Applied, StartedAt: event.OccurredAt, CompletedAt: event.OccurredAt, - ModelCalls: event.ModelCalls, InputTokens: event.InputTokens, OutputTokens: event.OutputTokens, - } - return record.Validate() -} - -type UsageExporterConfig struct { - Journal EvaluationJournalStore - Endpoint string - AckPath string - BearerToken string - HTTPClient *http.Client - Interval time.Duration - BatchSize int -} - -// DurableEvaluationUsageStore keeps delivery state beside shared evaluation -// records. It removes local acknowledgement files from multi-replica fleets; -// duplicate concurrent delivery remains safe through the required remote -// idempotency key. -type DurableEvaluationUsageStore interface { - EvaluationJournalStore - PendingEvaluationRecords(context.Context, int) ([]EvaluationRecord, error) - MarkEvaluationExported(context.Context, string) error - PendingEvaluationCount(context.Context) (int, error) -} - -// BatchEvaluationUsageStore atomically acknowledges a delivered cohort. It is -// optional so file-backed and third-party durable stores retain the original -// per-unit contract. -type BatchEvaluationUsageStore interface { - DurableEvaluationUsageStore - MarkEvaluationsExported(context.Context, []string) error -} - -// UsageExportStatus is a bounded operational snapshot. It deliberately -// exposes no usage payload, endpoint, token, or raw network error: operators -// need to know whether export is keeping up without turning diagnostics into a -// second source of sensitive decision context. -type UsageExportStatus struct { - Pending int `json:"pending"` - LastAttemptAt int64 `json:"last_attempt_at,omitempty"` - LastSuccessAt int64 `json:"last_success_at,omitempty"` - LastFailureAt int64 `json:"last_failure_at,omitempty"` - LastFailureCode string `json:"last_failure_code,omitempty"` -} - -func (status UsageExportStatus) Validate() error { - if status.Pending < 0 || status.LastAttemptAt < 0 || status.LastSuccessAt < 0 || status.LastFailureAt < 0 { - return fmt.Errorf("decision: invalid usage export status") - } - switch status.LastFailureCode { - case "", UsageExportFailureTransport, UsageExportFailureRemoteRejected, UsageExportFailureInvalidAcknowledged, UsageExportFailureAckPersistence: - default: - return fmt.Errorf("decision: invalid usage export failure code") - } - if status.LastFailureCode != "" && status.LastFailureAt == 0 { - return fmt.Errorf("decision: usage export failure code requires observation time") - } - return nil -} - -// UsageExporter runs outside the authorization call path. It acknowledges a -// unit locally only after the remote endpoint returns the same unit ID; local -// ack failure causes a safe idempotent resend. -type UsageExporter struct { - journal EvaluationJournalStore - endpoint *url.URL - ackPath string - bearerToken string - httpClient *http.Client - interval time.Duration - batchSize int - - mu sync.Mutex - acked map[string]struct{} - status UsageExportStatus -} - -func NewUsageExporter(config UsageExporterConfig) (*UsageExporter, error) { - if config.Journal == nil || config.Endpoint == "" { - return nil, fmt.Errorf("decision: usage journal and endpoint are required") - } - endpoint, err := url.Parse(config.Endpoint) - if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { - return nil, fmt.Errorf("decision: invalid usage endpoint") - } - if endpoint.Scheme != "https" && !(endpoint.Scheme == "http" && usageLoopback(endpoint.Hostname())) { - return nil, fmt.Errorf("decision: usage endpoint must use HTTPS") - } - ackPath := "" - if _, durable := config.Journal.(DurableEvaluationUsageStore); !durable { - if config.AckPath == "" { - return nil, fmt.Errorf("decision: file-backed usage journal requires an ack path") - } - ackPath, err = filepath.Abs(filepath.Clean(config.AckPath)) - if err != nil { - return nil, err - } - if info, statErr := os.Lstat(ackPath); statErr == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { - return nil, fmt.Errorf("decision: usage ack path must be an owner-only regular file") - } - } else if !os.IsNotExist(statErr) { - return nil, statErr - } - if err := os.MkdirAll(filepath.Dir(ackPath), 0o700); err != nil { - return nil, err - } - } - if config.HTTPClient == nil { - config.HTTPClient = &http.Client{Timeout: 10 * time.Second} - } - if config.Interval <= 0 { - config.Interval = 30 * time.Second - } - if config.BatchSize <= 0 { - config.BatchSize = 100 - } - exporter := &UsageExporter{ - journal: config.Journal, endpoint: endpoint, ackPath: ackPath, - bearerToken: config.BearerToken, httpClient: config.HTTPClient, - interval: config.Interval, batchSize: config.BatchSize, acked: make(map[string]struct{}), - } - if err := exporter.loadAcks(); err != nil { - return nil, err - } - return exporter, nil -} - -func (exporter *UsageExporter) Run(ctx context.Context) error { - ticker := time.NewTicker(exporter.interval) - defer ticker.Stop() - for { - _ = exporter.ExportOnce(ctx) - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - } - } -} - -func (exporter *UsageExporter) ExportOnce(ctx context.Context) error { - exporter.mu.Lock() - defer exporter.mu.Unlock() - var ( - records []EvaluationRecord - err error - ) - durable, durableJournal := exporter.journal.(DurableEvaluationUsageStore) - if durableJournal { - records, err = durable.PendingEvaluationRecords(ctx, exporter.batchSize) - } else { - records, err = exporter.journal.EvaluationRecords(ctx, 0) - } - if err != nil { - return fmt.Errorf("decision: load pending usage: %w", err) - } - attempted := 0 - var firstErr error - failureCode := "" - batchedAcknowledgements := make([]string, 0, len(records)) - _, batchDurable := exporter.journal.(BatchEvaluationUsageStore) - for _, record := range records { - if !durableJournal { - if _, exists := exporter.acked[record.ID]; exists { - continue - } - } - if attempted >= exporter.batchSize { - break - } - attempted++ - event := record.UsageEvent() - if err := exporter.send(ctx, event); err != nil { - if firstErr == nil { - firstErr = err - failureCode = usageExportFailureCode(err) - } - continue - } - if durableJournal { - if batchDurable { - batchedAcknowledgements = append(batchedAcknowledgements, event.UnitID) - continue - } - if err := durable.MarkEvaluationExported(ctx, event.UnitID); err != nil { - if firstErr == nil { - firstErr = fmt.Errorf("decision: persist usage acknowledgement: %w", err) - failureCode = UsageExportFailureAckPersistence - } - continue - } - exporter.acked[event.UnitID] = struct{}{} - continue - } - if err := fsutil.AppendSync(exporter.ackPath, []byte(event.UnitID+"\n")); err != nil { - if firstErr == nil { - firstErr = fmt.Errorf("decision: persist usage acknowledgement: %w", err) - failureCode = UsageExportFailureAckPersistence - } - continue - } - exporter.acked[event.UnitID] = struct{}{} - } - if len(batchedAcknowledgements) > 0 { - batchStore := exporter.journal.(BatchEvaluationUsageStore) - if err := batchStore.MarkEvaluationsExported(ctx, batchedAcknowledgements); err != nil { - if firstErr == nil { - firstErr = fmt.Errorf("decision: persist usage acknowledgements: %w", err) - failureCode = UsageExportFailureAckPersistence - } - } else { - for _, unitID := range batchedAcknowledgements { - exporter.acked[unitID] = struct{}{} - } - } - } - if attempted > 0 { - now := time.Now().UTC().Unix() - exporter.status.LastAttemptAt = now - if firstErr != nil { - exporter.status.LastFailureAt = now - exporter.status.LastFailureCode = failureCode - } else { - exporter.status.LastSuccessAt = now - exporter.status.LastFailureCode = "" - } - } - return firstErr -} - -func (exporter *UsageExporter) Pending() int { - exporter.mu.Lock() - defer exporter.mu.Unlock() - return exporter.pendingLocked() -} - -// Status returns the current delivery backlog and generic health markers. It -// is safe to expose on a management read path and never affects evaluation or -// authorization. -func (exporter *UsageExporter) Status() UsageExportStatus { - if exporter == nil { - return UsageExportStatus{} - } - exporter.mu.Lock() - defer exporter.mu.Unlock() - status := exporter.status - status.Pending = exporter.pendingLocked() - return status -} - -func (exporter *UsageExporter) pendingLocked() int { - if durable, ok := exporter.journal.(DurableEvaluationUsageStore); ok { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - pending, err := durable.PendingEvaluationCount(ctx) - if err == nil { - return pending - } - return exporter.status.Pending - } - records, err := exporter.journal.EvaluationRecords(context.Background(), 0) - if err != nil { - return exporter.status.Pending - } - pending := 0 - for _, record := range records { - if _, exists := exporter.acked[record.ID]; !exists { - pending++ - } - } - return pending -} - -func usageExportFailureCode(err error) string { - if err == nil { - return "" - } - message := err.Error() - switch { - case strings.Contains(message, "returned HTTP"): - return UsageExportFailureRemoteRejected - case strings.Contains(message, "invalid acknowledgement"): - return UsageExportFailureInvalidAcknowledged - default: - return UsageExportFailureTransport - } -} - -func (exporter *UsageExporter) send(ctx context.Context, event UsageEvent) error { - if err := event.Validate(); err != nil { - return err - } - body, _ := json.Marshal(event) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, exporter.endpoint.String(), bytes.NewReader(body)) - if err != nil { - return err - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json") - request.Header.Set("Idempotency-Key", event.UnitID) - if exporter.bearerToken != "" { - request.Header.Set("Authorization", "Bearer "+exporter.bearerToken) - } - response, err := exporter.httpClient.Do(request) - if err != nil { - return fmt.Errorf("decision: export usage: %w", err) - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10)) - return fmt.Errorf("decision: usage endpoint returned HTTP %d", response.StatusCode) - } - decoder := json.NewDecoder(io.LimitReader(response.Body, 4<<10)) - decoder.DisallowUnknownFields() - var acknowledgement struct { - AcceptedUnitID string `json:"accepted_unit_id"` - } - if err := decoder.Decode(&acknowledgement); err != nil || acknowledgement.AcceptedUnitID != event.UnitID { - return fmt.Errorf("decision: usage endpoint returned invalid acknowledgement") - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return fmt.Errorf("decision: usage endpoint returned trailing data") - } - return nil -} - -func (exporter *UsageExporter) loadAcks() error { - if _, durable := exporter.journal.(DurableEvaluationUsageStore); durable { - return nil - } - file, err := os.Open(exporter.ackPath) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return err - } - defer file.Close() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - unitID := scanner.Text() - if !lowerHex(unitID, 64) { - return fmt.Errorf("decision: invalid usage acknowledgement %q", unitID) - } - exporter.acked[unitID] = struct{}{} - } - return scanner.Err() -} - -func usageLoopback(host string) bool { - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} diff --git a/decision/usage_export_test.go b/decision/usage_export_test.go deleted file mode 100644 index ca1cd87..0000000 --- a/decision/usage_export_test.go +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" -) - -type durableUsageJournalStub struct { - mu sync.Mutex - record EvaluationRecord - exported bool -} - -func (journal *durableUsageJournalStub) RecordEvaluation(_ context.Context, record EvaluationRecord) error { - journal.mu.Lock() - defer journal.mu.Unlock() - journal.record = record - return nil -} - -func (journal *durableUsageJournalStub) EvaluationRecords(context.Context, int) ([]EvaluationRecord, error) { - journal.mu.Lock() - defer journal.mu.Unlock() - return []EvaluationRecord{journal.record}, nil -} - -func (journal *durableUsageJournalStub) PendingEvaluationRecords(context.Context, int) ([]EvaluationRecord, error) { - journal.mu.Lock() - defer journal.mu.Unlock() - if journal.exported { - return nil, nil - } - return []EvaluationRecord{journal.record}, nil -} - -func (journal *durableUsageJournalStub) MarkEvaluationExported(_ context.Context, unitID string) error { - journal.mu.Lock() - defer journal.mu.Unlock() - if unitID == journal.record.ID { - journal.exported = true - } - return nil -} - -func (journal *durableUsageJournalStub) PendingEvaluationCount(context.Context) (int, error) { - journal.mu.Lock() - defer journal.mu.Unlock() - if journal.exported { - return 0, nil - } - return 1, nil -} - -func usageRecord() EvaluationRecord { - return EvaluationRecord{ - Version: SchemaVersion, ID: strings.Repeat("a", 64), IntentHash: strings.Repeat("b", 64), - TenantID: "tenant-a", AgentID: "agent-1", Mode: EvaluationShadow, - Identity: evaluatorIdentity(), BaseOutcome: Allow, SemanticOutcome: Deny, AppliedOutcome: Allow, - ModelCalls: 1, InputTokens: 41, OutputTokens: 7, StartedAt: 1, CompletedAt: 2, - } -} - -func TestUsageExporterRetriesAndAcknowledgesIdempotently(t *testing.T) { - t.Parallel() - journal, err := OpenEvaluationJournal(filepath.Join(t.TempDir(), "evaluations.jsonl")) - if err != nil { - t.Fatal(err) - } - if err := journal.RecordEvaluation(context.Background(), usageRecord()); err != nil { - t.Fatal(err) - } - var calls atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - call := calls.Add(1) - if request.Header.Get("Idempotency-Key") != usageRecord().ID { - t.Errorf("idempotency key=%q", request.Header.Get("Idempotency-Key")) - } - if call == 1 { - writer.WriteHeader(http.StatusServiceUnavailable) - return - } - var event UsageEvent - if err := json.NewDecoder(request.Body).Decode(&event); err != nil { - t.Errorf("decode usage event: %v", err) - } else if event.ModelCalls != 1 || event.InputTokens != 41 || event.OutputTokens != 7 { - t.Errorf("usage event=%+v", event) - } - writer.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(writer).Encode(map[string]string{"accepted_unit_id": usageRecord().ID}) - })) - defer server.Close() - ackPath := filepath.Join(t.TempDir(), "usage.acks") - exporter, err := NewUsageExporter(UsageExporterConfig{Journal: journal, Endpoint: server.URL, AckPath: ackPath}) - if err != nil { - t.Fatal(err) - } - if err := exporter.ExportOnce(context.Background()); err == nil || exporter.Pending() != 1 { - t.Fatalf("failed export err=%v pending=%d", err, exporter.Pending()) - } - failed := exporter.Status() - if failed.Pending != 1 || failed.LastFailureAt == 0 || failed.LastFailureCode != UsageExportFailureRemoteRejected || failed.LastSuccessAt != 0 { - t.Fatalf("failed export status=%+v", failed) - } - if err := exporter.ExportOnce(context.Background()); err != nil || exporter.Pending() != 0 { - t.Fatalf("successful retry err=%v pending=%d", err, exporter.Pending()) - } - succeeded := exporter.Status() - if succeeded.Pending != 0 || succeeded.LastAttemptAt == 0 || succeeded.LastSuccessAt == 0 || succeeded.LastFailureCode != "" { - t.Fatalf("successful export status=%+v", succeeded) - } - restarted, err := NewUsageExporter(UsageExporterConfig{Journal: journal, Endpoint: server.URL, AckPath: ackPath}) - if err != nil { - t.Fatal(err) - } - if err := restarted.ExportOnce(context.Background()); err != nil || calls.Load() != 2 { - t.Fatalf("restart resent acknowledged usage: calls=%d err=%v", calls.Load(), err) - } -} - -func TestUsageExporterRejectsWrongAcknowledgementAndInsecureEndpoint(t *testing.T) { - t.Parallel() - journal, _ := OpenEvaluationJournal(filepath.Join(t.TempDir(), "evaluations.jsonl")) - _ = journal.RecordEvaluation(context.Background(), usageRecord()) - if _, err := NewUsageExporter(UsageExporterConfig{Journal: journal, Endpoint: "http://example.com/usage", AckPath: filepath.Join(t.TempDir(), "acks")}); err == nil { - t.Fatal("non-loopback plaintext usage endpoint was accepted") - } - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - _ = json.NewEncoder(writer).Encode(map[string]string{"accepted_unit_id": strings.Repeat("c", 64)}) - })) - defer server.Close() - exporter, err := NewUsageExporter(UsageExporterConfig{Journal: journal, Endpoint: server.URL, AckPath: filepath.Join(t.TempDir(), "acks")}) - if err != nil { - t.Fatal(err) - } - if err := exporter.ExportOnce(context.Background()); err == nil || exporter.Pending() != 1 { - t.Fatalf("wrong acknowledgement err=%v pending=%d", err, exporter.Pending()) - } - if status := exporter.Status(); status.LastFailureCode != UsageExportFailureInvalidAcknowledged || status.LastFailureAt == 0 { - t.Fatalf("wrong acknowledgement status=%+v", status) - } -} - -func TestUsageExporterUsesSharedDurableAcknowledgement(t *testing.T) { - t.Parallel() - journal := &durableUsageJournalStub{record: usageRecord()} - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(writer).Encode(map[string]string{"accepted_unit_id": usageRecord().ID}) - })) - defer server.Close() - exporter, err := NewUsageExporter(UsageExporterConfig{Journal: journal, Endpoint: server.URL}) - if err != nil { - t.Fatal(err) - } - if err := exporter.ExportOnce(context.Background()); err != nil || exporter.Pending() != 0 { - t.Fatalf("durable export err=%v pending=%d", err, exporter.Pending()) - } -} - -func TestUsageExportStatusRejectsMalformedRemoteState(t *testing.T) { - t.Parallel() - for name, status := range map[string]UsageExportStatus{ - "negative backlog": {Pending: -1}, - "unknown failure": {LastFailureAt: 1, LastFailureCode: "network detail"}, - "failure without timestamp": {LastFailureCode: UsageExportFailureTransport}, - } { - t.Run(name, func(t *testing.T) { - if err := status.Validate(); err == nil { - t.Fatalf("invalid usage export status accepted: %+v", status) - } - }) - } -} diff --git a/decision/workflow.go b/decision/workflow.go deleted file mode 100644 index 5107db4..0000000 --- a/decision/workflow.go +++ /dev/null @@ -1,760 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "sort" - "time" -) - -const ( - ApprovalTransactionDomain = "pilot-approval-transaction-v1" - ApprovalVoteDomain = "pilot-approval-vote-v1" - ApprovalCertificateDomain = "pilot-approval-certificate-v1" - MaxApprovalTransactionTTL = 7 * 24 * time.Hour - MaxApprovalKeys = 32 - MaxApprovalVotes = 32 -) - -type ApprovalVoteChoice string - -const ( - ApprovalVoteApprove ApprovalVoteChoice = "approve" - ApprovalVoteReject ApprovalVoteChoice = "reject" -) - -// ApprovalTransaction is a decision-authority-signed, long-lived proposal. -// Its exact action fields and proposed outcome are the maximum authority that -// a later threshold certificate can unlock. -type ApprovalTransaction struct { - Version uint16 `json:"version"` - ID string `json:"id"` - InitialDecisionHash string `json:"initial_decision_hash"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Action string `json:"action"` - Resource string `json:"resource"` - PayloadHash string `json:"payload_hash"` - Risk RiskClass `json:"risk"` - Outcome Outcome `json:"outcome"` - Constraints []Constraint `json:"constraints,omitempty"` - PolicyRevision uint64 `json:"policy_revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - ApproverKeyIDs []string `json:"approver_key_ids"` - RequiredApprovals uint16 `json:"required_approvals"` - CreatedAt int64 `json:"created_at"` - ExpiresAt int64 `json:"expires_at"` - ProviderID string `json:"provider_id"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -// ApprovalVote is one purpose-limited approval-key vote over an exact -// transaction. Distinct key IDs, not self-asserted display names, satisfy the -// threshold. -type ApprovalVote struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TransactionHash string `json:"transaction_hash"` - TenantID string `json:"tenant_id"` - ApproverID string `json:"approver_id"` - Choice ApprovalVoteChoice `json:"choice"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - Nonce string `json:"nonce"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -// ApprovalCertificate is the decision authority's signed threshold result. -// It repeats the transaction's exact authority and the sorted vote hashes so -// an enforcer can verify the workflow without trusting mutable server state. -type ApprovalCertificate struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TransactionHash string `json:"transaction_hash"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Outcome Outcome `json:"outcome"` - Constraints []Constraint `json:"constraints,omitempty"` - PolicyRevision uint64 `json:"policy_revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - ApprovalVoteHashes []string `json:"approval_vote_hashes"` - FinalizedAt int64 `json:"finalized_at"` - ExpiresAt int64 `json:"expires_at"` - ProviderID string `json:"provider_id"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func NewApprovalTransaction(intent Intent, initial Decision, outcome Outcome, constraints []Constraint, approverKeyIDs []string, required uint16, createdAt, expiresAt time.Time, providerID, keyID string) (ApprovalTransaction, error) { - intentHash, err := intent.Hash() - if err != nil { - return ApprovalTransaction{}, err - } - initialHash, err := initial.Hash() - if err != nil { - return ApprovalTransaction{}, err - } - if initial.Outcome != ApprovalRequired || initial.IntentHash != intentHash || initial.TenantID != intent.TenantID || initial.AgentID != intent.AgentID { - return ApprovalTransaction{}, fmt.Errorf("decision: long approval requires a bound approval_required decision") - } - if createdAt.Unix() < initial.IssuedAt-int64(MaxClockSkew/time.Second) || createdAt.Unix() > initial.ExpiresAt+int64(MaxClockSkew/time.Second) { - return ApprovalTransaction{}, fmt.Errorf("decision: approval transaction was not created during the initial decision window") - } - transaction := ApprovalTransaction{ - Version: SchemaVersion, InitialDecisionHash: initialHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Action: intent.Action, - Resource: intent.Resource, PayloadHash: intent.PayloadHash, Risk: intent.Risk, - Outcome: outcome, Constraints: append([]Constraint(nil), constraints...), - PolicyRevision: initial.PolicyRevision, RevocationEpoch: initial.RevocationEpoch, - ApproverKeyIDs: append([]string(nil), approverKeyIDs...), RequiredApprovals: required, - CreatedAt: createdAt.Unix(), ExpiresAt: expiresAt.Unix(), ProviderID: providerID, KeyID: keyID, - } - transaction.ID = approvalTransactionID(transaction) - if err := transaction.Validate(); err != nil { - return ApprovalTransaction{}, err - } - return transaction, nil -} - -func (transaction ApprovalTransaction) Validate() error { - if transaction.Version != SchemaVersion || !lowerHex(transaction.ID, 64) || !lowerHex(transaction.InitialDecisionHash, 64) || !lowerHex(transaction.PayloadHash, 64) { - return fmt.Errorf("decision: invalid approval transaction identity") - } - for name, value := range map[string]string{ - "tenant_id": transaction.TenantID, "agent_id": transaction.AgentID, - "provider_id": transaction.ProviderID, "key_id": transaction.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if !validAction(transaction.Action) { - return fmt.Errorf("decision: invalid approval transaction action") - } - if err := validateText("resource", transaction.Resource, 1024, false); err != nil { - return err - } - switch transaction.Risk { - case RiskLow, RiskMedium, RiskHigh, RiskCritical: - default: - return fmt.Errorf("decision: invalid approval transaction risk") - } - if err := validateProposedAuthority(transaction.TenantID, transaction.AgentID, transaction.Outcome, transaction.Constraints); err != nil { - return err - } - if transaction.PolicyRevision == 0 || transaction.RequiredApprovals == 0 || len(transaction.ApproverKeyIDs) == 0 || - len(transaction.ApproverKeyIDs) > MaxApprovalKeys || int(transaction.RequiredApprovals) > len(transaction.ApproverKeyIDs) { - return fmt.Errorf("decision: invalid approval transaction threshold") - } - seen := make(map[string]struct{}, len(transaction.ApproverKeyIDs)) - for _, keyID := range transaction.ApproverKeyIDs { - if err := validateIdentifier("approver_key_id", keyID); err != nil { - return err - } - if _, exists := seen[keyID]; exists { - return fmt.Errorf("decision: duplicate approval transaction key") - } - seen[keyID] = struct{}{} - } - if transaction.CreatedAt <= 0 || transaction.ExpiresAt <= transaction.CreatedAt || - transaction.ExpiresAt-transaction.CreatedAt > int64(MaxApprovalTransactionTTL/time.Second) { - return fmt.Errorf("decision: invalid approval transaction validity window") - } - if transaction.ID != approvalTransactionID(transaction) { - return fmt.Errorf("decision: noncanonical approval transaction id") - } - return nil -} - -func (transaction ApprovalTransaction) Canonical() ([]byte, error) { - if err := transaction.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(ApprovalTransactionDomain) - writer.u16(transaction.Version) - writer.string(transaction.ID) - writer.string(transaction.InitialDecisionHash) - writer.string(transaction.TenantID) - writer.string(transaction.AgentID) - writer.string(transaction.Action) - writer.string(transaction.Resource) - writer.string(transaction.PayloadHash) - writer.string(string(transaction.Risk)) - writeProposedAuthority(&writer, transaction.Outcome, transaction.Constraints) - writer.u64(transaction.PolicyRevision) - writer.u64(transaction.RevocationEpoch) - keys := append([]string(nil), transaction.ApproverKeyIDs...) - sort.Strings(keys) - writer.u16(uint16(len(keys))) - for _, keyID := range keys { - writer.string(keyID) - } - writer.u16(transaction.RequiredApprovals) - writer.i64(transaction.CreatedAt) - writer.i64(transaction.ExpiresAt) - writer.string(transaction.ProviderID) - writer.string(transaction.KeyID) - return writer.Bytes(), nil -} - -func (transaction ApprovalTransaction) Hash() (string, error) { - return hashCanonical(transaction.Canonical()) -} - -func (transaction *ApprovalTransaction) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid approval transaction private key") - } - return transaction.SignWith(func(message []byte) ([]byte, error) { return ed25519.Sign(privateKey, message), nil }) -} - -func (transaction *ApprovalTransaction) SignWith(signer func([]byte) ([]byte, error)) error { - canonical, err := transaction.Canonical() - if err != nil { - return err - } - return setWorkflowSignature("approval transaction", &transaction.Signature, canonical, signer) -} - -func (transaction ApprovalTransaction) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := transaction.Canonical() - if err != nil { - return err - } - if err := verifyFreshLong("approval transaction", transaction.CreatedAt, transaction.ExpiresAt, now, MaxApprovalTransactionTTL); err != nil { - return err - } - return verifySignature("approval transaction", publicKey, canonical, transaction.Signature) -} - -func (transaction ApprovalTransaction) MatchesIntent(intent Intent) error { - if err := intent.Validate(); err != nil { - return err - } - if transaction.TenantID != intent.TenantID || transaction.AgentID != intent.AgentID || transaction.Action != intent.Action || - transaction.Resource != intent.Resource || transaction.PayloadHash != intent.PayloadHash || transaction.Risk != intent.Risk { - return fmt.Errorf("decision: approval transaction does not match execution intent") - } - return nil -} - -func NewApprovalVote(transaction ApprovalTransaction, approverID string, choice ApprovalVoteChoice, issuedAt, expiresAt time.Time, nonce, keyID string) (ApprovalVote, error) { - transactionHash, err := transaction.Hash() - if err != nil { - return ApprovalVote{}, err - } - vote := ApprovalVote{ - Version: SchemaVersion, TransactionHash: transactionHash, TenantID: transaction.TenantID, - ApproverID: approverID, Choice: choice, IssuedAt: issuedAt.Unix(), ExpiresAt: expiresAt.Unix(), - Nonce: nonce, KeyID: keyID, - } - vote.ID = approvalVoteID(vote) - if err := vote.Validate(); err != nil { - return ApprovalVote{}, err - } - return vote, nil -} - -func (vote ApprovalVote) Validate() error { - if vote.Version != SchemaVersion || !lowerHex(vote.ID, 64) || !lowerHex(vote.TransactionHash, 64) || !lowerHex(vote.Nonce, 32) { - return fmt.Errorf("decision: invalid approval vote identity") - } - for name, value := range map[string]string{"tenant_id": vote.TenantID, "approver_id": vote.ApproverID, "key_id": vote.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if vote.Choice != ApprovalVoteApprove && vote.Choice != ApprovalVoteReject { - return fmt.Errorf("decision: invalid approval vote choice") - } - if vote.IssuedAt <= 0 || vote.ExpiresAt <= vote.IssuedAt || vote.ExpiresAt-vote.IssuedAt > int64(MaxApprovalTransactionTTL/time.Second) { - return fmt.Errorf("decision: invalid approval vote validity window") - } - if vote.ID != approvalVoteID(vote) { - return fmt.Errorf("decision: noncanonical approval vote id") - } - return nil -} - -func (vote ApprovalVote) Canonical() ([]byte, error) { - if err := vote.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(ApprovalVoteDomain) - writer.u16(vote.Version) - writer.string(vote.ID) - writer.string(vote.TransactionHash) - writer.string(vote.TenantID) - writer.string(vote.ApproverID) - writer.string(string(vote.Choice)) - writer.i64(vote.IssuedAt) - writer.i64(vote.ExpiresAt) - writer.string(vote.Nonce) - writer.string(vote.KeyID) - return writer.Bytes(), nil -} - -func (vote ApprovalVote) Hash() (string, error) { return hashCanonical(vote.Canonical()) } - -func (vote *ApprovalVote) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid approval vote private key") - } - return vote.SignWith(func(message []byte) ([]byte, error) { return ed25519.Sign(privateKey, message), nil }) -} - -func (vote *ApprovalVote) SignWith(signer func([]byte) ([]byte, error)) error { - canonical, err := vote.Canonical() - if err != nil { - return err - } - return setWorkflowSignature("approval vote", &vote.Signature, canonical, signer) -} - -func (vote ApprovalVote) VerifyFor(transaction ApprovalTransaction, publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := vote.Canonical() - if err != nil { - return err - } - transactionHash, err := transaction.Hash() - if err != nil { - return err - } - if vote.TransactionHash != transactionHash || vote.TenantID != transaction.TenantID || !containsString(transaction.ApproverKeyIDs, vote.KeyID) || - vote.IssuedAt < transaction.CreatedAt-int64(MaxClockSkew/time.Second) || vote.ExpiresAt > transaction.ExpiresAt { - return fmt.Errorf("decision: approval vote transaction binding mismatch") - } - if err := verifyFreshLong("approval vote", vote.IssuedAt, vote.ExpiresAt, now, MaxApprovalTransactionTTL); err != nil { - return err - } - return verifySignature("approval vote", publicKey, canonical, vote.Signature) -} - -func IssueApprovalCertificate(transaction ApprovalTransaction, votes []ApprovalVote, approvalKeys map[string]ed25519.PublicKey, decisionPrivateKey ed25519.PrivateKey, finalizedAt time.Time, providerID, keyID string) (ApprovalCertificate, error) { - if len(decisionPrivateKey) != ed25519.PrivateKeySize { - return ApprovalCertificate{}, fmt.Errorf("decision: invalid approval certificate private key") - } - certificate, err := NewApprovalCertificate(transaction, votes, approvalKeys, decisionPrivateKey.Public().(ed25519.PublicKey), finalizedAt, providerID, keyID) - if err != nil { - return ApprovalCertificate{}, err - } - if err := certificate.Sign(decisionPrivateKey); err != nil { - return ApprovalCertificate{}, err - } - return certificate, nil -} - -func NewApprovalCertificate(transaction ApprovalTransaction, votes []ApprovalVote, approvalKeys map[string]ed25519.PublicKey, decisionPublicKey ed25519.PublicKey, finalizedAt time.Time, providerID, keyID string) (ApprovalCertificate, error) { - if len(votes) == 0 || len(votes) > MaxApprovalVotes { - return ApprovalCertificate{}, fmt.Errorf("decision: invalid approval vote count") - } - if err := transaction.Verify(decisionPublicKey, finalizedAt); err != nil { - return ApprovalCertificate{}, err - } - transactionHash, err := transaction.Hash() - if err != nil { - return ApprovalCertificate{}, err - } - seenKeys := make(map[string]struct{}, len(votes)) - voteHashes := make([]string, 0, len(votes)) - expiresAt := transaction.ExpiresAt - approved := 0 - for _, vote := range votes { - publicKey, exists := approvalKeys[vote.KeyID] - if !exists { - return ApprovalCertificate{}, fmt.Errorf("decision: approval vote key is unavailable") - } - if err := vote.VerifyFor(transaction, publicKey, finalizedAt); err != nil { - return ApprovalCertificate{}, err - } - if _, duplicate := seenKeys[vote.KeyID]; duplicate { - return ApprovalCertificate{}, fmt.Errorf("decision: approval threshold repeats a key") - } - seenKeys[vote.KeyID] = struct{}{} - if vote.Choice == ApprovalVoteReject { - return ApprovalCertificate{}, fmt.Errorf("decision: approval transaction was rejected") - } - approved++ - if vote.ExpiresAt < expiresAt { - expiresAt = vote.ExpiresAt - } - hash, _ := vote.Hash() - voteHashes = append(voteHashes, hash) - } - if approved < int(transaction.RequiredApprovals) { - return ApprovalCertificate{}, fmt.Errorf("decision: approval threshold is not satisfied") - } - sort.Strings(voteHashes) - certificate := ApprovalCertificate{ - Version: SchemaVersion, TransactionHash: transactionHash, - TenantID: transaction.TenantID, AgentID: transaction.AgentID, - Outcome: transaction.Outcome, Constraints: append([]Constraint(nil), transaction.Constraints...), - PolicyRevision: transaction.PolicyRevision, RevocationEpoch: transaction.RevocationEpoch, - ApprovalVoteHashes: voteHashes, FinalizedAt: finalizedAt.Unix(), ExpiresAt: expiresAt, - ProviderID: providerID, KeyID: keyID, - } - certificate.ID = approvalCertificateID(certificate) - if err := certificate.Validate(); err != nil { - return ApprovalCertificate{}, err - } - return certificate, nil -} - -func (certificate ApprovalCertificate) Validate() error { - if certificate.Version != SchemaVersion || !lowerHex(certificate.ID, 64) || !lowerHex(certificate.TransactionHash, 64) { - return fmt.Errorf("decision: invalid approval certificate identity") - } - for name, value := range map[string]string{ - "tenant_id": certificate.TenantID, "agent_id": certificate.AgentID, - "provider_id": certificate.ProviderID, "key_id": certificate.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if err := validateProposedAuthority(certificate.TenantID, certificate.AgentID, certificate.Outcome, certificate.Constraints); err != nil { - return err - } - if certificate.PolicyRevision == 0 || certificate.FinalizedAt <= 0 || certificate.ExpiresAt <= certificate.FinalizedAt || - certificate.ExpiresAt-certificate.FinalizedAt > int64(MaxApprovalTransactionTTL/time.Second) || - len(certificate.ApprovalVoteHashes) == 0 || len(certificate.ApprovalVoteHashes) > MaxApprovalVotes { - return fmt.Errorf("decision: invalid approval certificate state") - } - seen := make(map[string]struct{}, len(certificate.ApprovalVoteHashes)) - for _, hash := range certificate.ApprovalVoteHashes { - if !lowerHex(hash, 64) { - return fmt.Errorf("decision: invalid approval certificate vote hash") - } - if _, exists := seen[hash]; exists { - return fmt.Errorf("decision: duplicate approval certificate vote hash") - } - seen[hash] = struct{}{} - } - if certificate.ID != approvalCertificateID(certificate) { - return fmt.Errorf("decision: noncanonical approval certificate id") - } - return nil -} - -func (certificate ApprovalCertificate) Canonical() ([]byte, error) { - if err := certificate.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(ApprovalCertificateDomain) - writer.u16(certificate.Version) - writer.string(certificate.ID) - writer.string(certificate.TransactionHash) - writer.string(certificate.TenantID) - writer.string(certificate.AgentID) - writeProposedAuthority(&writer, certificate.Outcome, certificate.Constraints) - writer.u64(certificate.PolicyRevision) - writer.u64(certificate.RevocationEpoch) - hashes := append([]string(nil), certificate.ApprovalVoteHashes...) - sort.Strings(hashes) - writer.u16(uint16(len(hashes))) - for _, hash := range hashes { - writer.string(hash) - } - writer.i64(certificate.FinalizedAt) - writer.i64(certificate.ExpiresAt) - writer.string(certificate.ProviderID) - writer.string(certificate.KeyID) - return writer.Bytes(), nil -} - -func (certificate ApprovalCertificate) Hash() (string, error) { - return hashCanonical(certificate.Canonical()) -} - -func (certificate *ApprovalCertificate) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid approval certificate private key") - } - return certificate.SignWith(func(message []byte) ([]byte, error) { return ed25519.Sign(privateKey, message), nil }) -} - -func (certificate *ApprovalCertificate) SignWith(signer func([]byte) ([]byte, error)) error { - canonical, err := certificate.Canonical() - if err != nil { - return err - } - return setWorkflowSignature("approval certificate", &certificate.Signature, canonical, signer) -} - -func (certificate ApprovalCertificate) VerifyFor(transaction ApprovalTransaction, votes []ApprovalVote, approvalKeys map[string]ed25519.PublicKey, decisionPublicKey ed25519.PublicKey, now time.Time) error { - canonical, err := certificate.Canonical() - if err != nil { - return err - } - if err := verifyFreshLong("approval certificate", certificate.FinalizedAt, certificate.ExpiresAt, now, MaxApprovalTransactionTTL); err != nil { - return err - } - if err := verifySignature("approval certificate", decisionPublicKey, canonical, certificate.Signature); err != nil { - return err - } - expected, err := approvalCertificateView(transaction, votes, approvalKeys, certificate.FinalizedAt) - if err != nil { - return err - } - if certificate.TransactionHash != expected.TransactionHash || certificate.TenantID != expected.TenantID || certificate.AgentID != expected.AgentID || - certificate.Outcome != expected.Outcome || !equalConstraints(certificate.Constraints, expected.Constraints) || - certificate.PolicyRevision != expected.PolicyRevision || certificate.RevocationEpoch != expected.RevocationEpoch || - certificate.ExpiresAt != expected.ExpiresAt || !equalStringsSorted(certificate.ApprovalVoteHashes, expected.ApprovalVoteHashes) { - return fmt.Errorf("decision: approval certificate threshold binding mismatch") - } - return nil -} - -// VerifyApprovedExecution binds a fresh workload-signed intent to a long-lived -// approval certificate. Callers must separately verify the intent signature -// and atomically consume the transaction before producing side effects. -func VerifyApprovedExecution(intent Intent, transaction ApprovalTransaction, certificate ApprovalCertificate, votes []ApprovalVote, approvalKeys map[string]ed25519.PublicKey, decisionPublicKey ed25519.PublicKey, now time.Time) error { - if err := transaction.Verify(decisionPublicKey, now); err != nil { - return err - } - if err := transaction.MatchesIntent(intent); err != nil { - return err - } - if err := certificate.VerifyFor(transaction, votes, approvalKeys, decisionPublicKey, now); err != nil { - return err - } - if intent.IssuedAt < certificate.FinalizedAt-int64(MaxClockSkew/time.Second) || intent.ExpiresAt > certificate.ExpiresAt { - return fmt.Errorf("decision: execution intent is outside the approval certificate window") - } - return nil -} - -func approvalCertificateView(transaction ApprovalTransaction, votes []ApprovalVote, approvalKeys map[string]ed25519.PublicKey, finalizedAt int64) (ApprovalCertificate, error) { - if len(votes) == 0 || len(votes) > MaxApprovalVotes { - return ApprovalCertificate{}, fmt.Errorf("decision: invalid approval vote count") - } - transactionHash, err := transaction.Hash() - if err != nil { - return ApprovalCertificate{}, err - } - seenKeys := make(map[string]struct{}, len(votes)) - var hashes []string - expiresAt := transaction.ExpiresAt - approved := 0 - for _, vote := range votes { - key, exists := approvalKeys[vote.KeyID] - if !exists { - return ApprovalCertificate{}, fmt.Errorf("decision: approval vote key is unavailable") - } - if err := vote.VerifyFor(transaction, key, time.Unix(finalizedAt, 0)); err != nil { - return ApprovalCertificate{}, err - } - if _, exists := seenKeys[vote.KeyID]; exists { - return ApprovalCertificate{}, fmt.Errorf("decision: approval threshold repeats a key") - } - seenKeys[vote.KeyID] = struct{}{} - if vote.Choice == ApprovalVoteReject { - return ApprovalCertificate{}, fmt.Errorf("decision: approval transaction was rejected") - } - approved++ - if vote.ExpiresAt < expiresAt { - expiresAt = vote.ExpiresAt - } - hash, _ := vote.Hash() - hashes = append(hashes, hash) - } - if approved < int(transaction.RequiredApprovals) { - return ApprovalCertificate{}, fmt.Errorf("decision: approval threshold is not satisfied") - } - sort.Strings(hashes) - return ApprovalCertificate{ - TransactionHash: transactionHash, TenantID: transaction.TenantID, AgentID: transaction.AgentID, - Outcome: transaction.Outcome, Constraints: append([]Constraint(nil), transaction.Constraints...), - PolicyRevision: transaction.PolicyRevision, RevocationEpoch: transaction.RevocationEpoch, - ApprovalVoteHashes: hashes, ExpiresAt: expiresAt, - }, nil -} - -func validateProposedAuthority(tenantID, agentID string, outcome Outcome, constraints []Constraint) error { - probe := Decision{ - Version: SchemaVersion, ID: "approval-authority", IntentHash: "0000000000000000000000000000000000000000000000000000000000000000", - TenantID: tenantID, AgentID: agentID, Outcome: outcome, Constraints: constraints, - PolicyRevision: 1, ProviderID: "approval-authority", IssuedAt: 1, ExpiresAt: 2, KeyID: "approval-authority", - } - if outcome != Allow && outcome != Constrain { - return fmt.Errorf("decision: approval transaction outcome must allow or constrain") - } - if err := probe.Validate(); err != nil { - return err - } - return nil -} - -func writeProposedAuthority(writer *canonicalWriter, outcome Outcome, constraints []Constraint) { - writer.string(string(outcome)) - ordered := append([]Constraint(nil), constraints...) - sort.Slice(ordered, func(i, j int) bool { - if ordered[i].Key != ordered[j].Key { - return ordered[i].Key < ordered[j].Key - } - if ordered[i].Operator != ordered[j].Operator { - return ordered[i].Operator < ordered[j].Operator - } - return ordered[i].Value < ordered[j].Value - }) - writer.u16(uint16(len(ordered))) - for _, constraint := range ordered { - writer.string(constraint.Key) - writer.string(constraint.Operator) - writer.string(constraint.Value) - } -} - -func approvalTransactionID(transaction ApprovalTransaction) string { - writer := canonicalWriter{} - writer.string(ApprovalTransactionDomain + "/id") - writer.string(transaction.InitialDecisionHash) - writer.string(transaction.TenantID) - writer.string(transaction.AgentID) - writer.string(transaction.Action) - writer.string(transaction.Resource) - writer.string(transaction.PayloadHash) - writer.string(string(transaction.Risk)) - writeProposedAuthority(&writer, transaction.Outcome, transaction.Constraints) - writer.u64(transaction.PolicyRevision) - writer.u64(transaction.RevocationEpoch) - keys := append([]string(nil), transaction.ApproverKeyIDs...) - sort.Strings(keys) - writer.u16(uint16(len(keys))) - for _, keyID := range keys { - writer.string(keyID) - } - writer.u16(transaction.RequiredApprovals) - writer.i64(transaction.CreatedAt) - writer.i64(transaction.ExpiresAt) - writer.string(transaction.ProviderID) - writer.string(transaction.KeyID) - sum := sha256.Sum256(writer.Bytes()) - return hex.EncodeToString(sum[:]) -} - -func approvalVoteID(vote ApprovalVote) string { - writer := canonicalWriter{} - writer.string(ApprovalVoteDomain + "/id") - writer.string(vote.TransactionHash) - writer.string(vote.TenantID) - writer.string(vote.ApproverID) - writer.string(string(vote.Choice)) - writer.i64(vote.IssuedAt) - writer.i64(vote.ExpiresAt) - writer.string(vote.Nonce) - writer.string(vote.KeyID) - sum := sha256.Sum256(writer.Bytes()) - return hex.EncodeToString(sum[:]) -} - -func approvalCertificateID(certificate ApprovalCertificate) string { - writer := canonicalWriter{} - writer.string(ApprovalCertificateDomain + "/id") - writer.string(certificate.TransactionHash) - writer.string(certificate.TenantID) - writer.string(certificate.AgentID) - writeProposedAuthority(&writer, certificate.Outcome, certificate.Constraints) - writer.u64(certificate.PolicyRevision) - writer.u64(certificate.RevocationEpoch) - hashes := append([]string(nil), certificate.ApprovalVoteHashes...) - sort.Strings(hashes) - writer.u16(uint16(len(hashes))) - for _, hash := range hashes { - writer.string(hash) - } - writer.i64(certificate.FinalizedAt) - writer.i64(certificate.ExpiresAt) - writer.string(certificate.ProviderID) - writer.string(certificate.KeyID) - sum := sha256.Sum256(writer.Bytes()) - return hex.EncodeToString(sum[:]) -} - -func verifyFreshLong(name string, issuedAt, expiresAt int64, now time.Time, maximum time.Duration) error { - nowUnix := now.Unix() - if issuedAt > nowUnix+int64(MaxClockSkew/time.Second) { - return fmt.Errorf("decision: %s is from the future", name) - } - if expiresAt < nowUnix || expiresAt-issuedAt > int64(maximum/time.Second) { - return fmt.Errorf("decision: %s is expired or exceeds its validity limit", name) - } - return nil -} - -func containsString(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} - -func equalStringsSorted(left, right []string) bool { - if len(left) != len(right) { - return false - } - a := append([]string(nil), left...) - b := append([]string(nil), right...) - sort.Strings(a) - sort.Strings(b) - for index := range a { - if a[index] != b[index] { - return false - } - } - return true -} - -func equalConstraints(left, right []Constraint) bool { - if len(left) != len(right) { - return false - } - a := append([]Constraint(nil), left...) - b := append([]Constraint(nil), right...) - order := func(values []Constraint) { - sort.Slice(values, func(i, j int) bool { - if values[i].Key != values[j].Key { - return values[i].Key < values[j].Key - } - if values[i].Operator != values[j].Operator { - return values[i].Operator < values[j].Operator - } - return values[i].Value < values[j].Value - }) - } - order(a) - order(b) - for index := range a { - if a[index] != b[index] { - return false - } - } - return true -} - -func setWorkflowSignature(name string, target *string, canonical []byte, signer func([]byte) ([]byte, error)) error { - if signer == nil { - return fmt.Errorf("decision: %s signer is required", name) - } - signature, err := signer(canonical) - if err != nil { - return fmt.Errorf("decision: sign %s: %w", name, err) - } - if len(signature) != ed25519.SignatureSize { - return fmt.Errorf("decision: %s signer returned invalid signature length", name) - } - *target = base64.StdEncoding.EncodeToString(signature) - return nil -} diff --git a/decision/workflow_cancellation.go b/decision/workflow_cancellation.go deleted file mode 100644 index a9a98c8..0000000 --- a/decision/workflow_cancellation.go +++ /dev/null @@ -1,128 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "crypto/ed25519" - "crypto/sha256" - "encoding/hex" - "fmt" - "time" -) - -const ApprovalCancellationDomain = "pilot-approval-cancellation-v1" - -// ApprovalCancellation is a decision-authority-signed terminal transition for -// an unconsumed approval transaction. It gives operators durable evidence that -// an approval was deliberately stopped rather than merely left to expire. -type ApprovalCancellation struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TransactionHash string `json:"transaction_hash"` - TenantID string `json:"tenant_id"` - Reason string `json:"reason"` - CancelledAt int64 `json:"cancelled_at"` - ProviderID string `json:"provider_id"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func NewApprovalCancellation(transaction ApprovalTransaction, reason string, cancelledAt time.Time, providerID, keyID string) (ApprovalCancellation, error) { - transactionHash, err := transaction.Hash() - if err != nil { - return ApprovalCancellation{}, err - } - cancellation := ApprovalCancellation{ - Version: SchemaVersion, TransactionHash: transactionHash, TenantID: transaction.TenantID, - Reason: reason, CancelledAt: cancelledAt.Unix(), ProviderID: providerID, KeyID: keyID, - } - cancellation.ID = approvalCancellationID(cancellation) - if err := cancellation.Validate(); err != nil { - return ApprovalCancellation{}, err - } - return cancellation, nil -} - -func (cancellation ApprovalCancellation) Validate() error { - if cancellation.Version != SchemaVersion || !lowerHex(cancellation.ID, 64) || !lowerHex(cancellation.TransactionHash, 64) { - return fmt.Errorf("decision: invalid approval cancellation identity") - } - for name, value := range map[string]string{ - "tenant_id": cancellation.TenantID, "provider_id": cancellation.ProviderID, "key_id": cancellation.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if err := validateText("reason", cancellation.Reason, 512, false); err != nil { - return err - } - if cancellation.CancelledAt <= 0 || cancellation.ID != approvalCancellationID(cancellation) { - return fmt.Errorf("decision: invalid approval cancellation state") - } - return nil -} - -func (cancellation ApprovalCancellation) Canonical() ([]byte, error) { - if err := cancellation.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(ApprovalCancellationDomain) - writer.u16(cancellation.Version) - writer.string(cancellation.ID) - writer.string(cancellation.TransactionHash) - writer.string(cancellation.TenantID) - writer.string(cancellation.Reason) - writer.i64(cancellation.CancelledAt) - writer.string(cancellation.ProviderID) - writer.string(cancellation.KeyID) - return writer.Bytes(), nil -} - -func (cancellation ApprovalCancellation) Hash() (string, error) { - return hashCanonical(cancellation.Canonical()) -} - -func (cancellation *ApprovalCancellation) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("decision: invalid approval cancellation private key") - } - return cancellation.SignWith(func(message []byte) ([]byte, error) { return ed25519.Sign(privateKey, message), nil }) -} - -func (cancellation *ApprovalCancellation) SignWith(signer func([]byte) ([]byte, error)) error { - canonical, err := cancellation.Canonical() - if err != nil { - return err - } - return setWorkflowSignature("approval cancellation", &cancellation.Signature, canonical, signer) -} - -func (cancellation ApprovalCancellation) VerifyFor(transaction ApprovalTransaction, decisionPublicKey ed25519.PublicKey, now time.Time) error { - canonical, err := cancellation.Canonical() - if err != nil { - return err - } - transactionHash, err := transaction.Hash() - if err != nil || cancellation.TransactionHash != transactionHash || cancellation.TenantID != transaction.TenantID { - return fmt.Errorf("decision: approval cancellation transaction binding mismatch") - } - if cancellation.CancelledAt > now.Unix()+int64(MaxClockSkew/time.Second) || cancellation.CancelledAt < now.Unix()-int64(MaxClockSkew/time.Second) { - return fmt.Errorf("decision: approval cancellation is not fresh") - } - return verifySignature("approval cancellation", decisionPublicKey, canonical, cancellation.Signature) -} - -func approvalCancellationID(cancellation ApprovalCancellation) string { - writer := canonicalWriter{} - writer.string(ApprovalCancellationDomain + "/id") - writer.string(cancellation.TransactionHash) - writer.string(cancellation.TenantID) - writer.string(cancellation.Reason) - writer.i64(cancellation.CancelledAt) - writer.string(cancellation.ProviderID) - writer.string(cancellation.KeyID) - sum := sha256.Sum256(writer.Bytes()) - return hex.EncodeToString(sum[:]) -} diff --git a/decision/workflow_test.go b/decision/workflow_test.go deleted file mode 100644 index 7b44ee6..0000000 --- a/decision/workflow_test.go +++ /dev/null @@ -1,236 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decision - -import ( - "bytes" - "crypto/ed25519" - "crypto/rand" - "strings" - "testing" - "time" -) - -func TestLongApprovalConformanceVector(t *testing.T) { - decisionPrivate := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x77}, ed25519.SeedSize)) - approvalPrivate1 := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x88}, ed25519.SeedSize)) - approvalPrivate2 := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x99}, ed25519.SeedSize)) - now := time.Unix(1785500000, 0) - intent := Intent{ - Version: SchemaVersion, ID: "workflow-vector-intent", TenantID: "tenant-vector", AgentID: "agent-vector", - Action: "wallet.pay", Resource: "invoice/vector", PayloadHash: HashPayload([]byte("workflow-vector")), Risk: RiskCritical, - IssuedAt: now.Unix(), ExpiresAt: now.Add(2 * time.Minute).Unix(), Nonce: strings.Repeat("a", 32), KeyID: "agent-key-vector", - } - intentHash, _ := intent.Hash() - initial := Decision{ - Version: SchemaVersion, ID: "workflow-vector-decision", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: ApprovalRequired, - PolicyRevision: 12, RevocationEpoch: 4, ProviderID: "workflow-vector-provider", - IssuedAt: now.Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "decision-key-vector", - } - _ = initial.Sign(decisionPrivate) - transaction, err := NewApprovalTransaction( - intent, initial, Constrain, []Constraint{{Key: "amount", Operator: "max", Value: "500"}}, - []string{"approval-key-b", "approval-key-a"}, 2, now, now.Add(24*time.Hour), - "workflow-vector-provider", "decision-key-vector", - ) - if err != nil { - t.Fatal(err) - } - _ = transaction.Sign(decisionPrivate) - vote1, _ := NewApprovalVote(transaction, "approver-a", ApprovalVoteApprove, now.Add(time.Hour), now.Add(20*time.Hour), strings.Repeat("b", 32), "approval-key-a") - _ = vote1.Sign(approvalPrivate1) - vote2, _ := NewApprovalVote(transaction, "approver-b", ApprovalVoteApprove, now.Add(90*time.Minute), now.Add(22*time.Hour), strings.Repeat("c", 32), "approval-key-b") - _ = vote2.Sign(approvalPrivate2) - keys := map[string]ed25519.PublicKey{ - "approval-key-a": approvalPrivate1.Public().(ed25519.PublicKey), - "approval-key-b": approvalPrivate2.Public().(ed25519.PublicKey), - } - certificate, err := IssueApprovalCertificate(transaction, []ApprovalVote{vote2, vote1}, keys, decisionPrivate, now.Add(2*time.Hour), "workflow-vector-provider", "decision-key-vector") - if err != nil { - t.Fatal(err) - } - transactionHash, _ := transaction.Hash() - vote1Hash, _ := vote1.Hash() - vote2Hash, _ := vote2.Hash() - certificateHash, _ := certificate.Hash() - const expectedTransactionHash = "d58a72d62c189cc8ccb07adf4e9324d8694b2c3d50714d3f419a3c57556057f2" - const expectedTransactionSignature = "VY1k9QlfVoz8giiL/r9sT81cl+IrRxRXGtGkxQW1J9w7IiqUmIBDLtr27EcfneHMKvWFziZ6R/xitB4H2882AA==" - const expectedVote1Hash = "919ba22460608de5ceb9be6eb2ce4730b770cb598731b6a1b80732c4de3aa96a" - const expectedVote1Signature = "J+hSN+QAV/fk9XZozZU0td/PCYmT8FM8k+qd5sp02MoWUQGMRgWATePoHDk6X5cEo55+ZfTUOr6Ez2OtGiEhBw==" - const expectedVote2Hash = "4514256a97752c76a052c30e5055ea132ab40c9610a69699a5c8d2c25e16cc7c" - const expectedVote2Signature = "jp/t2CZUyOd5GSq4z5KRO2wBJCwiJFxxsfXyLqrBYK7RO5Jm4X2CBLIb9EtDNQUyZDs2Z/gvMozylfUq7olGCQ==" - const expectedCertificateHash = "7f4d608a560df1c49e1cfac6ba75187ee8a6b6b10a7ac16eb3e2f501e1c35fc2" - const expectedCertificateSignature = "R+n7zwZS5WpTWlA5fKsMRDX+dc8rpdqh3dkntzL8bhugaELFx90ANImixhR9yUtrzNdKLQ1UhaOjvOz51LtiAQ==" - if transactionHash != expectedTransactionHash || transaction.Signature != expectedTransactionSignature || - vote1Hash != expectedVote1Hash || vote1.Signature != expectedVote1Signature || - vote2Hash != expectedVote2Hash || vote2.Signature != expectedVote2Signature || - certificateHash != expectedCertificateHash || certificate.Signature != expectedCertificateSignature { - t.Fatalf("update vector: transaction_hash=%s transaction_signature=%s vote1_hash=%s vote1_signature=%s vote2_hash=%s vote2_signature=%s certificate_hash=%s certificate_signature=%s", - transactionHash, transaction.Signature, vote1Hash, vote1.Signature, vote2Hash, vote2.Signature, certificateHash, certificate.Signature) - } -} - -func TestApprovalCancellationBindsTransactionAndIsFresh(t *testing.T) { - t.Parallel() - fixture := newWorkflowFixture(t) - cancellation, err := NewApprovalCancellation(fixture.transaction, "operator cancelled", fixture.now, "managed-workflow", "decision-key-1") - if err != nil { - t.Fatal(err) - } - if err := cancellation.Sign(fixture.decisionPrivate); err != nil { - t.Fatal(err) - } - if err := cancellation.VerifyFor(fixture.transaction, fixture.decisionPublic, fixture.now); err != nil { - t.Fatal(err) - } - tampered := cancellation - tampered.Reason = "changed" - if err := tampered.VerifyFor(fixture.transaction, fixture.decisionPublic, fixture.now); err == nil { - t.Fatal("tampered cancellation was accepted") - } - if err := cancellation.VerifyFor(fixture.transaction, fixture.decisionPublic, fixture.now.Add(MaxClockSkew+time.Second)); err == nil { - t.Fatal("stale cancellation was accepted") - } -} - -type workflowFixture struct { - now time.Time - intent Intent - initial Decision - transaction ApprovalTransaction - votes []ApprovalVote - certificate ApprovalCertificate - decisionPublic ed25519.PublicKey - decisionPrivate ed25519.PrivateKey - approvalPublic1 ed25519.PublicKey - approvalPrivate1 ed25519.PrivateKey - approvalPublic2 ed25519.PublicKey - approvalPrivate2 ed25519.PrivateKey -} - -func newWorkflowFixture(t *testing.T) workflowFixture { - t.Helper() - now := time.Unix(1785500000, 0) - decisionPublic, decisionPrivate, _ := ed25519.GenerateKey(rand.Reader) - approvalPublic1, approvalPrivate1, _ := ed25519.GenerateKey(rand.Reader) - approvalPublic2, approvalPrivate2, _ := ed25519.GenerateKey(rand.Reader) - intent := testIntent(t, now) - intentHash, _ := intent.Hash() - initial := Decision{ - Version: SchemaVersion, ID: "workflow-initial", IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: ApprovalRequired, - PolicyRevision: 9, RevocationEpoch: 3, ProviderID: "managed-workflow", - IssuedAt: now.Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "decision-key-1", - } - if err := initial.Sign(decisionPrivate); err != nil { - t.Fatal(err) - } - transaction, err := NewApprovalTransaction( - intent, initial, Constrain, []Constraint{{Key: "amount", Operator: "max", Value: "100"}}, - []string{"approval-key-2", "approval-key-1"}, 2, now, now.Add(24*time.Hour), - "managed-workflow", "decision-key-1", - ) - if err != nil { - t.Fatal(err) - } - if err := transaction.Sign(decisionPrivate); err != nil { - t.Fatal(err) - } - vote1, err := NewApprovalVote(transaction, "approver-1", ApprovalVoteApprove, now.Add(time.Hour), now.Add(20*time.Hour), strings.Repeat("1", 32), "approval-key-1") - if err != nil { - t.Fatal(err) - } - _ = vote1.Sign(approvalPrivate1) - vote2, err := NewApprovalVote(transaction, "approver-2", ApprovalVoteApprove, now.Add(90*time.Minute), now.Add(22*time.Hour), strings.Repeat("2", 32), "approval-key-2") - if err != nil { - t.Fatal(err) - } - _ = vote2.Sign(approvalPrivate2) - keys := map[string]ed25519.PublicKey{"approval-key-1": approvalPublic1, "approval-key-2": approvalPublic2} - certificate, err := IssueApprovalCertificate(transaction, []ApprovalVote{vote2, vote1}, keys, decisionPrivate, now.Add(2*time.Hour), "managed-workflow", "decision-key-1") - if err != nil { - t.Fatal(err) - } - return workflowFixture{ - now: now, intent: intent, initial: initial, transaction: transaction, - votes: []ApprovalVote{vote1, vote2}, certificate: certificate, - decisionPublic: decisionPublic, decisionPrivate: decisionPrivate, - approvalPublic1: approvalPublic1, approvalPrivate1: approvalPrivate1, - approvalPublic2: approvalPublic2, approvalPrivate2: approvalPrivate2, - } -} - -func TestLongApprovalBindsThresholdToFreshExecutionIntent(t *testing.T) { - t.Parallel() - fixture := newWorkflowFixture(t) - fresh := fixture.intent - fresh.ID = "workflow-execution" - fresh.IssuedAt = fixture.now.Add(2 * time.Hour).Unix() - fresh.ExpiresAt = fixture.now.Add(2*time.Hour + 2*time.Minute).Unix() - fresh.Nonce = strings.Repeat("3", 32) - fresh.Signature = "" - keys := map[string]ed25519.PublicKey{"approval-key-1": fixture.approvalPublic1, "approval-key-2": fixture.approvalPublic2} - if err := VerifyApprovedExecution(fresh, fixture.transaction, fixture.certificate, fixture.votes, keys, fixture.decisionPublic, fixture.now.Add(2*time.Hour)); err != nil { - t.Fatalf("valid long approval rejected: %v", err) - } - reordered := []ApprovalVote{fixture.votes[1], fixture.votes[0]} - if err := fixture.certificate.VerifyFor(fixture.transaction, reordered, keys, fixture.decisionPublic, fixture.now.Add(2*time.Hour)); err != nil { - t.Fatalf("vote ordering changed certificate semantics: %v", err) - } - tampered := fresh - tampered.Resource = "invoice/another" - if err := VerifyApprovedExecution(tampered, fixture.transaction, fixture.certificate, fixture.votes, keys, fixture.decisionPublic, fixture.now.Add(2*time.Hour)); err == nil { - t.Fatal("approval certificate crossed action resource") - } - if err := VerifyApprovedExecution(fresh, fixture.transaction, fixture.certificate, fixture.votes, keys, fixture.decisionPublic, fixture.now.Add(21*time.Hour)); err == nil { - t.Fatal("expired approval certificate was accepted") - } -} - -func TestLongApprovalRejectsMissingDuplicateUnlistedAndRejectVotes(t *testing.T) { - t.Parallel() - fixture := newWorkflowFixture(t) - keys := map[string]ed25519.PublicKey{"approval-key-1": fixture.approvalPublic1, "approval-key-2": fixture.approvalPublic2} - if _, err := IssueApprovalCertificate(fixture.transaction, fixture.votes[:1], keys, fixture.decisionPrivate, fixture.now.Add(2*time.Hour), "managed-workflow", "decision-key-1"); err == nil { - t.Fatal("one vote satisfied a two-key threshold") - } - duplicate, _ := NewApprovalVote(fixture.transaction, "approver-duplicate", ApprovalVoteApprove, fixture.now.Add(100*time.Minute), fixture.now.Add(20*time.Hour), strings.Repeat("4", 32), "approval-key-1") - _ = duplicate.Sign(fixture.approvalPrivate1) - if _, err := IssueApprovalCertificate(fixture.transaction, []ApprovalVote{fixture.votes[0], duplicate}, keys, fixture.decisionPrivate, fixture.now.Add(2*time.Hour), "managed-workflow", "decision-key-1"); err == nil { - t.Fatal("one approval key was counted twice") - } - rejected, _ := NewApprovalVote(fixture.transaction, "approver-2", ApprovalVoteReject, fixture.now.Add(90*time.Minute), fixture.now.Add(22*time.Hour), strings.Repeat("5", 32), "approval-key-2") - _ = rejected.Sign(fixture.approvalPrivate2) - if _, err := IssueApprovalCertificate(fixture.transaction, []ApprovalVote{fixture.votes[0], rejected}, keys, fixture.decisionPrivate, fixture.now.Add(2*time.Hour), "managed-workflow", "decision-key-1"); err == nil { - t.Fatal("rejected workflow produced a certificate") - } - thirdPublic, thirdPrivate, _ := ed25519.GenerateKey(rand.Reader) - unlisted, _ := NewApprovalVote(fixture.transaction, "approver-3", ApprovalVoteApprove, fixture.now.Add(time.Hour), fixture.now.Add(20*time.Hour), strings.Repeat("6", 32), "approval-key-3") - _ = unlisted.Sign(thirdPrivate) - keys["approval-key-3"] = thirdPublic - if _, err := IssueApprovalCertificate(fixture.transaction, []ApprovalVote{fixture.votes[0], unlisted}, keys, fixture.decisionPrivate, fixture.now.Add(2*time.Hour), "managed-workflow", "decision-key-1"); err == nil { - t.Fatal("unlisted approval key satisfied threshold") - } -} - -func TestLongApprovalObjectsRejectAuthorityExpansionAndTamper(t *testing.T) { - t.Parallel() - fixture := newWorkflowFixture(t) - if _, err := NewApprovalTransaction( - fixture.intent, fixture.initial, Allow, []Constraint{{Key: "amount", Operator: "max", Value: "1000"}}, - []string{"approval-key-1"}, 1, fixture.now, fixture.now.Add(time.Hour), "managed", "decision-key-1", - ); err == nil { - t.Fatal("allow transaction carried constraints") - } - tampered := fixture.certificate - tampered.Constraints[0].Value = "1000" - keys := map[string]ed25519.PublicKey{"approval-key-1": fixture.approvalPublic1, "approval-key-2": fixture.approvalPublic2} - if err := tampered.VerifyFor(fixture.transaction, fixture.votes, keys, fixture.decisionPublic, fixture.now.Add(2*time.Hour)); err == nil { - t.Fatal("expanded certificate constraints were accepted") - } - wrongDecisionPublic, _, _ := ed25519.GenerateKey(rand.Reader) - if err := fixture.transaction.Verify(wrongDecisionPublic, fixture.now.Add(time.Hour)); err == nil { - t.Fatal("transaction signed by another authority was accepted") - } -}