diff --git a/actionhook/hook.go b/actionhook/hook.go new file mode 100644 index 0000000..fa8b097 --- /dev/null +++ b/actionhook/hook.go @@ -0,0 +1,317 @@ +// 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 new file mode 100644 index 0000000..4036cb4 --- /dev/null +++ b/actionhook/hook_test.go @@ -0,0 +1,45 @@ +// 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 new file mode 100644 index 0000000..95ef11f --- /dev/null +++ b/decision/approval.go @@ -0,0 +1,400 @@ +// 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 new file mode 100644 index 0000000..d1f9ef3 --- /dev/null +++ b/decision/approval_test.go @@ -0,0 +1,150 @@ +// 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 new file mode 100644 index 0000000..7036b78 --- /dev/null +++ b/decision/decision.go @@ -0,0 +1,558 @@ +// 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 new file mode 100644 index 0000000..8ad4883 --- /dev/null +++ b/decision/decision_test.go @@ -0,0 +1,198 @@ +// 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 new file mode 100644 index 0000000..baf3f4f --- /dev/null +++ b/decision/disclosure.go @@ -0,0 +1,181 @@ +// 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 new file mode 100644 index 0000000..68a5ee2 --- /dev/null +++ b/decision/disclosure_test.go @@ -0,0 +1,110 @@ +// 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 new file mode 100644 index 0000000..7818aa5 --- /dev/null +++ b/decision/enforcer.go @@ -0,0 +1,146 @@ +// 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 new file mode 100644 index 0000000..e00d6a1 --- /dev/null +++ b/decision/enforcer_test.go @@ -0,0 +1,224 @@ +// 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 new file mode 100644 index 0000000..c8e9f68 --- /dev/null +++ b/decision/evaluation_journal.go @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000..44b181e --- /dev/null +++ b/decision/evaluator.go @@ -0,0 +1,576 @@ +// 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 new file mode 100644 index 0000000..efc3e26 --- /dev/null +++ b/decision/evaluator_attestation.go @@ -0,0 +1,135 @@ +// 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 new file mode 100644 index 0000000..57bfb6a --- /dev/null +++ b/decision/evaluator_attestation_test.go @@ -0,0 +1,39 @@ +// 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 new file mode 100644 index 0000000..12d7705 --- /dev/null +++ b/decision/evaluator_test.go @@ -0,0 +1,462 @@ +// 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 new file mode 100644 index 0000000..a8a633c --- /dev/null +++ b/decision/federated_content.go @@ -0,0 +1,72 @@ +// 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 new file mode 100644 index 0000000..5c5e8a3 --- /dev/null +++ b/decision/federation_result.go @@ -0,0 +1,161 @@ +// 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 new file mode 100644 index 0000000..6ebb9b1 --- /dev/null +++ b/decision/mandate.go @@ -0,0 +1,381 @@ +// 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 new file mode 100644 index 0000000..4767e47 --- /dev/null +++ b/decision/mandate_bundle.go @@ -0,0 +1,161 @@ +// 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 new file mode 100644 index 0000000..f5ebb4a --- /dev/null +++ b/decision/mandate_store.go @@ -0,0 +1,112 @@ +// 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 new file mode 100644 index 0000000..96cdb2c --- /dev/null +++ b/decision/mandate_test.go @@ -0,0 +1,246 @@ +// 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 new file mode 100644 index 0000000..6bbff7e --- /dev/null +++ b/decision/model_usage.go @@ -0,0 +1,69 @@ +// 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 new file mode 100644 index 0000000..4ba8b70 --- /dev/null +++ b/decision/presidio.go @@ -0,0 +1,192 @@ +// 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 new file mode 100644 index 0000000..ba68c63 --- /dev/null +++ b/decision/presidio_external_test.go @@ -0,0 +1,24 @@ +// 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 new file mode 100644 index 0000000..39999be --- /dev/null +++ b/decision/presidio_test.go @@ -0,0 +1,70 @@ +// 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 new file mode 100644 index 0000000..56f14fb --- /dev/null +++ b/decision/receipt.go @@ -0,0 +1,318 @@ +// 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 new file mode 100644 index 0000000..246f29b --- /dev/null +++ b/decision/receipt_export.go @@ -0,0 +1,226 @@ +// 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() + 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 new file mode 100644 index 0000000..ca122d8 --- /dev/null +++ b/decision/receipt_export_test.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package decision + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "sync/atomic" + "testing" +) + +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 new file mode 100644 index 0000000..69fa2f1 --- /dev/null +++ b/decision/receipt_journal.go @@ -0,0 +1,163 @@ +// 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 +} + +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.load(); 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...) +} + +func (journal *ReceiptJournal) load() error { + file, err := os.Open(journal.path) + if os.IsNotExist(err) { + return nil + } + 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") + } + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64<<10), MaxReceiptJournalLineBytes+1) + line := 0 + 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) + } + journal.seen[receipt.ID] = hash + journal.receipts = append(journal.receipts, receipt) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("decision: scan receipt journal: %w", err) + } + 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 new file mode 100644 index 0000000..157fe06 --- /dev/null +++ b/decision/receipt_journal_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package decision + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "os" + "path/filepath" + "testing" + "time" +) + +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") + } +} diff --git a/decision/receipt_syslog.go b/decision/receipt_syslog.go new file mode 100644 index 0000000..f099a72 --- /dev/null +++ b/decision/receipt_syslog.go @@ -0,0 +1,37 @@ +// 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 new file mode 100644 index 0000000..d08472f --- /dev/null +++ b/decision/receipt_syslog_test.go @@ -0,0 +1,34 @@ +// 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 new file mode 100644 index 0000000..f3541c8 --- /dev/null +++ b/decision/receipt_test.go @@ -0,0 +1,196 @@ +// 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 new file mode 100644 index 0000000..cd73f35 --- /dev/null +++ b/decision/semantic_policy.go @@ -0,0 +1,191 @@ +// 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 new file mode 100644 index 0000000..f51089f --- /dev/null +++ b/decision/transfer_quota.go @@ -0,0 +1,102 @@ +// 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 new file mode 100644 index 0000000..23e146a --- /dev/null +++ b/decision/transfer_quota_test.go @@ -0,0 +1,59 @@ +// 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 new file mode 100644 index 0000000..ee4b5a7 --- /dev/null +++ b/decision/usage_export.go @@ -0,0 +1,431 @@ +// 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 new file mode 100644 index 0000000..ca1cd87 --- /dev/null +++ b/decision/usage_export_test.go @@ -0,0 +1,182 @@ +// 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 new file mode 100644 index 0000000..5107db4 --- /dev/null +++ b/decision/workflow.go @@ -0,0 +1,760 @@ +// 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 new file mode 100644 index 0000000..a9a98c8 --- /dev/null +++ b/decision/workflow_cancellation.go @@ -0,0 +1,128 @@ +// 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 new file mode 100644 index 0000000..7b44ee6 --- /dev/null +++ b/decision/workflow_test.go @@ -0,0 +1,236 @@ +// 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") + } +}