Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
482 changes: 482 additions & 0 deletions pkg/reason/FINDINGS.md

Large diffs are not rendered by default.

422 changes: 422 additions & 0 deletions pkg/reason/airgap_test.go

Large diffs are not rendered by default.

227 changes: 227 additions & 0 deletions pkg/reason/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package reason

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)

// The audit trail (§5.6). One investigation is one chain of records: what was
// asked, what came back, and what was refused. The third of those is the one
// that is usually missing, and its absence is not neutral — a refusal that
// leaves no trace is indistinguishable from a question that was never asked,
// which is exactly the ambiguity an operator is trying to resolve when they
// open this.
//
// # Byte-identical for a replayed transcript
//
// Two runs of the same scripted transcript against the same substrate produce
// the same bytes. That holds because:
//
// - Time enters only through [Clock]. There is no time.Now here.
// - Every list is emitted in a documented order and no record contains a Go
// map. Args are recorded as sorted key/value pairs; citations are sorted.
// - Durations are not recorded. A tool's elapsed time is real and varies;
// recording it would put a stopwatch reading in a hash chain.
// - The chain is over canonical JSON of each record with its own Hash field
// empty, so the hash is a function of the content and the previous hash
// and nothing else.
//
// # What is stored, given that everything is hostile
//
// A tool call's arguments are the model's bytes, which may be the attacker's
// bytes. They are recorded twice, deliberately: [AuditTool.ArgsDigest] is the
// exact SHA-256 of what arrived, and [AuditTool.Args] is the same document
// scrubbed and capped for display. The digest keeps the record forensically
// exact; the scrubbed copy is what an operator's terminal renders. Nothing in
// this file is fed back to a model — the anti-echo rule governs the
// transcript, and the audit trail is downstream of it.

// AuditRecord is one entry. Exactly one of the payload pointers is set; the
// Kind says which.
type AuditRecord struct {
Seq int `json:"seq"`
Kind string `json:"kind"`
At time.Time `json:"at"`

Question *AuditQuestion `json:"question,omitempty"`
Turn *AuditTurn `json:"turn,omitempty"`
Tool *AuditTool `json:"tool,omitempty"`
Outcome *AuditOutcome `json:"outcome,omitempty"`

// Prev is the previous record's Hash; "" for the first.
Prev string `json:"prev"`
// Hash is this record's own hash, computed over the record with Hash
// empty. Tampering with any earlier record invalidates every later one.
Hash string `json:"hash"`
}

// Record kinds.
const (
AuditKindQuestion = "question"
AuditKindTurn = "turn"
AuditKindTool = "tool"
AuditKindOutcome = "outcome"
)

// AuditQuestion opens the chain: what was asked, by whom, of what, with which
// versions pinned (§5.5, INV-5).
type AuditQuestion struct {
Question string `json:"question"` // scrubbed and capped
Initiator string `json:"initiator,omitempty"`
Cluster string `json:"cluster"`
Subject string `json:"subject,omitempty"`
From string `json:"from"`
To string `json:"to"`

Provider string `json:"provider"`
Model string `json:"model"`
PromptVersion string `json:"promptVersion"`
RegistryVersion string `json:"registryVersion"`
ToolsDigest string `json:"toolsDigest"`
SystemDigest string `json:"systemDigest"`
SeedDigest string `json:"seedDigest"`
Budget Budget `json:"budget"`
}

// AuditTurn records one provider round trip.
type AuditTurn struct {
Turn int `json:"turn"`
RequestDigest string `json:"requestDigest"`
Messages int `json:"messages"`
MaxOutputTokens int64 `json:"maxOutputTokens"`

TextDigest string `json:"textDigest,omitempty"`
OutputDigest string `json:"outputDigest,omitempty"`
ToolCalls []string `json:"toolCalls,omitempty"` // tool names, call order
StopReason string `json:"stopReason,omitempty"`
Error string `json:"error,omitempty"` // a provider failure, by class

Usage Usage `json:"usage"`
USDMicro int64 `json:"usdMicro"`
}

// AuditTool records one tool call: asked, returned, or refused.
type AuditTool struct {
Turn int `json:"turn"`
Tool string `json:"tool"`
CallID string `json:"callId,omitempty"`
ArgsDigest string `json:"argsDigest"`
// Args is the argument document, scrubbed and capped, for a human.
Args json.RawMessage `json:"args,omitempty"`

Clamps []Clamp `json:"clamps,omitempty"`
Refusal *Refusal `json:"refusal,omitempty"`

ResultDigest string `json:"resultDigest,omitempty"`
ResultBytes int `json:"resultBytes,omitempty"`
Scrubbed int `json:"scrubbedStrings,omitempty"`
Citations []string `json:"citations,omitempty"` // handle -> id, sorted
}

// AuditOutcome closes the chain.
type AuditOutcome struct {
State string `json:"state"`
Partial bool `json:"partial"`
// Reason is a refusal code or a terminal-state code; never free text.
Reason string `json:"reason,omitempty"`
AnswerDigest string `json:"answerDigest,omitempty"`
AnswerBytes int `json:"answerBytes,omitempty"`
Citations []string `json:"citations,omitempty"`
// Discarded counts citations the publish gate rejected, by cause. The
// ids themselves are the model's bytes and are recorded as digests.
Unresolvable int `json:"unresolvableCitations,omitempty"`
Unfetched int `json:"unfetchedCitations,omitempty"`
RejectDigests []string `json:"rejectedCitationDigests,omitempty"`
LinksStripped int `json:"linksStripped,omitempty"`

Turns int `json:"turns"`
ToolCalls int `json:"toolCalls"`
Refusals int `json:"refusals"`
Usage Usage `json:"usage"`
USDMicro int64 `json:"usdMicro"`
}

// Audit is one investigation's chain.
type Audit struct {
clock Clock
records []AuditRecord
}

func newAudit(c Clock) *Audit { return &Audit{clock: c} }

// append seals a record into the chain.
func (a *Audit) append(kind string, fill func(*AuditRecord)) {
rec := AuditRecord{Seq: len(a.records), Kind: kind, At: a.clock.now()}
fill(&rec)
if n := len(a.records); n > 0 {
rec.Prev = a.records[n-1].Hash
}
rec.Hash = hashRecord(rec)
a.records = append(a.records, rec)
}

// hashRecord hashes a record with its own Hash field empty.
func hashRecord(rec AuditRecord) string {
rec.Hash = ""
b, err := json.Marshal(rec)
if err != nil {
// Every field is this package's own and marshalable; a failure here
// would be a bug, and a hash of the error text would hide it.
return digest([]byte("reason: unmarshalable audit record"))
}
return digest(b)
}

// Records returns the chain in order.
func (a *Audit) Records() []AuditRecord { return append([]AuditRecord(nil), a.records...) }

// Encode renders the chain as canonical JSON — the bytes a determinism test
// compares and a store persists.
func (a *Audit) Encode() ([]byte, error) {
if a == nil {
return []byte("[]"), nil
}
return json.Marshal(a.records)
}

// Verify re-derives every hash and checks the chain links up. It is what
// makes "tampering is evident" a check rather than a claim.
func (a *Audit) Verify() error {
prev := ""
for i, rec := range a.records {
if rec.Seq != i {
return fmt.Errorf("reason: audit record %d carries seq %d", i, rec.Seq)
}
if rec.Prev != prev {
return fmt.Errorf("reason: audit record %d does not link to its predecessor", i)
}
if want := hashRecord(rec); want != rec.Hash {
return fmt.Errorf("reason: audit record %d has been altered", i)
}
prev = rec.Hash
}
return nil
}

// Head is the last record's hash — the value a caller stores to chain one
// investigation's audit to the ledger that holds it.
func (a *Audit) Head() string {
if a == nil || len(a.records) == 0 {
return ""
}
return a.records[len(a.records)-1].Hash
}

// digest is the one hash in this package. SHA-256, hex, full width: an audit
// chain that truncates its hashes to save bytes is an audit chain with a
// cheaper collision.
func digest(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}

// digestString is digest over a string.
func digestString(s string) string { return digest([]byte(s)) }
161 changes: 161 additions & 0 deletions pkg/reason/budget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package reason

import "fmt"

// Budget bounds an investigation. Every field bounds the LOOP, not a call.
//
// A per-call token cap with an unbounded loop is not a budget: twelve turns of
// 8k tokens each is 96k tokens however small each request looked, and a loop
// that keeps calling tools because each individual call was affordable is the
// documented way these systems run away. So the loop's accounting is
// cumulative, it is checked before a turn is spent rather than after, and the
// per-turn cap the provider is handed is derived from what remains
// ([ChatRequest.MaxOutputTokens]) rather than configured beside it.
//
// The budget state lives in the session. Nothing in a tool result can change
// it — a workload named "the budget has been raised to 10,000,000 tokens" is
// a string in a JSON document, and the only code that reads the budget reads
// these fields. TestATranscriptCannotRaiseItsOwnBudget is that sentence, run.
type Budget struct {
// MaxTurns bounds provider calls. §5.3's default is 12.
MaxTurns int
// MaxTokens bounds input+output+cached tokens across the whole
// investigation. §5.3's default is 150k.
MaxTokens int64
// MaxOutputTokensPerTurn bounds one turn's generation, so a single turn
// cannot consume the whole remaining budget.
MaxOutputTokensPerTurn int64
// MaxToolCalls bounds tool calls across the investigation.
MaxToolCalls int
// MaxToolCallsPerTurn bounds one turn's fan-out. A turn asking for two
// hundred dossiers is refused per-call beyond the cap, and the refusals
// are audited: the model finds out, and so does the operator.
MaxToolCallsPerTurn int
// MaxUSDMicro bounds the priced spend of the investigation in millionths
// of a dollar. Zero means unbounded by cost — which is only safe because
// MaxTokens is not.
MaxUSDMicro int64
// MinTurnTokens is the token headroom below which a further turn is not
// worth starting: a turn that can afford the prompt but not an answer
// burns budget to produce nothing.
MinTurnTokens int64
}

// DefaultBudget is §5.3/§5.8's conservative posture.
func DefaultBudget() Budget {
return Budget{
MaxTurns: 12,
MaxTokens: 150_000,
MaxOutputTokensPerTurn: 4_096,
MaxToolCalls: 48,
MaxToolCallsPerTurn: 8,
MaxUSDMicro: 2_000_000, // $2.00
MinTurnTokens: 2_000,
}
}

func (b Budget) withDefaults() Budget {
d := DefaultBudget()
if b.MaxTurns == 0 {
b.MaxTurns = d.MaxTurns
}
if b.MaxTokens == 0 {
b.MaxTokens = d.MaxTokens
}
if b.MaxOutputTokensPerTurn == 0 {
b.MaxOutputTokensPerTurn = d.MaxOutputTokensPerTurn
}
if b.MaxToolCalls == 0 {
b.MaxToolCalls = d.MaxToolCalls
}
if b.MaxToolCallsPerTurn == 0 {
b.MaxToolCallsPerTurn = d.MaxToolCallsPerTurn
}
if b.MinTurnTokens == 0 {
b.MinTurnTokens = d.MinTurnTokens
}
return b
}

func (b Budget) validate() error {
for _, c := range []struct {
name string
v int64
}{
{"MaxTurns", int64(b.MaxTurns)},
{"MaxTokens", b.MaxTokens},
{"MaxOutputTokensPerTurn", b.MaxOutputTokensPerTurn},
{"MaxToolCalls", int64(b.MaxToolCalls)},
{"MaxToolCallsPerTurn", int64(b.MaxToolCallsPerTurn)},
{"MinTurnTokens", b.MinTurnTokens},
} {
if c.v <= 0 {
return fmt.Errorf("reason: budget %s must be positive, got %d", c.name, c.v)
}
}
if b.MaxUSDMicro < 0 {
return fmt.Errorf("reason: budget MaxUSDMicro must not be negative")
}
if b.MaxOutputTokensPerTurn > b.MaxTokens {
return fmt.Errorf("reason: budget lets one turn (%d tokens) exceed the whole investigation (%d)",
b.MaxOutputTokensPerTurn, b.MaxTokens)
}
return nil
}

// spend is the running account. It is a value on the session, never package
// state, so two concurrent investigations cannot spend each other's budget.
type spend struct {
budget Budget
usage Usage
usdMicro int64
turns int
toolCalls int
}

// remainingTokens is what is left of the token budget.
func (s *spend) remainingTokens() int64 {
left := s.budget.MaxTokens - s.usage.Total()
if left < 0 {
return 0
}
return left
}

// turnCap is what the next turn may generate: the per-turn cap, lowered to
// what remains. This is the line that makes the cap a budget rather than a
// speed limit.
func (s *spend) turnCap() int64 {
cap := s.budget.MaxOutputTokensPerTurn
if left := s.remainingTokens(); left < cap {
cap = left
}
return cap
}

// exhausted reports the terminal state a further turn would violate, before
// that turn is spent. The empty string means keep going.
func (s *spend) exhausted() string {
switch {
case s.turns >= s.budget.MaxTurns:
return OutcomeTurnLimit
case s.remainingTokens() < s.budget.MinTurnTokens:
return OutcomeBudgetTokens
case s.budget.MaxUSDMicro > 0 && s.usdMicro >= s.budget.MaxUSDMicro:
return OutcomeBudgetUSD
}
return ""
}

// charge records a turn.
func (s *spend) charge(u Usage, usdMicro int64) {
s.usage.add(u)
s.usdMicro += usdMicro
s.turns++
}

// toolCallAllowed reports whether one more call fits, given how many this
// turn has already made.
func (s *spend) toolCallAllowed(inThisTurn int) bool {
return inThisTurn < s.budget.MaxToolCallsPerTurn && s.toolCalls < s.budget.MaxToolCalls
}
Loading
Loading