~bigbes/sr-ht-spec

ref: ef7bddf8b3d64cb4c204a064ad73dbacc78443cb sr-ht-spec/authn/provenance.go -rw-r--r-- 11.8 KiB
ef7bddf8 — Eugene Blikh ci: publish the apk into artifacts.sr.ht as well 3 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
package authn

import (
	"fmt"
	"strings"
	"unicode/utf8"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// ConfigSection is our config section. The literal ".sr.ht" suffix is what puts
// us in the nav network list and what other services look us up by, so it is a
// constant rather than a parameter.
const ConfigSection = "spec.sr.ht"

// Trailer keys recorded on every agent commit. Git trailers rather than a
// Postgres-only audit table, so provenance is visible in a plain `git log` on
// any clone and cannot drift from the content it describes.
//
// Only these two. The agent's own identity rides on the Author line, where git
// already puts "who wrote this", and duplicating it into a third trailer would
// create two spellings that can disagree.
const (
	TrailerAgentSession = "X-Agent-Session"
	TrailerAgentBase    = "X-Agent-Base"
)

// agentLocalPart is the mailbox of the synthetic address stamped on agent
// authorship. Agents have no mailbox; the address exists because git demands
// one, and it is made obviously non-human so nobody mails it.
const agentLocalPart = "agent"

const (
	// MaxAgentLen bounds the agent identity string. It becomes a git author
	// name, which is read by humans in a review UI.
	MaxAgentLen = 128

	// MaxSessionLen bounds the session ID. A UUID is 36 bytes; the slack is for
	// runners that prefix their own job identifiers.
	MaxSessionLen = 128

	// minRevLen and maxRevLen bound a git object name in the X-Agent-Base
	// trailer: an abbreviated sha at the low end, a full sha-256 at the high.
	minRevLen = 7
	maxRevLen = 64
)

// Signature is a git identity: the name and mailbox halves of an author or
// committer line.
type Signature struct {
	Name  string
	Email string
}

// String renders the identity in git's own "Name <email>" form.
func (s Signature) String() string { return s.Name + " <" + s.Email + ">" }

// Instance carries the identity facts an agent commit needs from the instance
// config. It is a value, so service/ can build it once at startup and hand
// copies around; every field is exported so a caller that has these facts from
// somewhere other than an ini file can construct it directly.
type Instance struct {
	// OwnerName and OwnerEmail are [sr.ht] owner-name / owner-email — the human
	// this instance belongs to. They become the committer of every agent write
	// and of every merge, which is what makes "an agent proposed it, bigbes'
	// service committed it" legible in `git log`.
	OwnerName  string
	OwnerEmail string

	// AgentEmail is the synthetic mailbox stamped on agent authorship.
	AgentEmail string
}

// InstanceFromConfig reads the provenance identities out of the instance
// config.
//
// It mirrors config.GetOwner without the panic: this is a library, and a
// missing key should fail the daemon's startup validation with a message that
// names the key, not unwind a request. Every failure wraps ErrMissingConfig.
//
// AgentEmail is derived as agent@<host of [spec.sr.ht] origin>, so no new
// config key exists to forget or to disagree with the origin. The design's
// worked example shows agent@srht.bigb.es (the bare cookie domain) rather than
// agent@spec.srht.bigb.es; the design never says where that address comes from,
// and deriving it from our own origin is the only rule that needs no operator
// input. A caller that wants the bare domain sets Instance.AgentEmail directly.
//
// The host is instconf.OriginHost and deliberately not instconf.OriginAuthority,
// whose doc names a synthesized email domain among its callers: the design pins
// this address at "agent@<host of [spec.sr.ht] origin>", and a port in the
// domain half — agent@localhost:5091 on a development instance — is not a
// mailbox. The port distinguishes two endpoints, which is what an audience needs
// and what an address nobody may mail does not.
func InstanceFromConfig(conf ini.File) (Instance, error) {
	ownerName, ok := conf.Get("sr.ht", "owner-name")
	if !ok {
		return Instance{}, fmt.Errorf("%w: [sr.ht] owner-name", ErrMissingConfig)
	}
	ownerEmail, ok := conf.Get("sr.ht", "owner-email")
	if !ok {
		return Instance{}, fmt.Errorf("%w: [sr.ht] owner-email", ErrMissingConfig)
	}
	origin := instconf.ExternalOrigin(conf, ConfigSection)
	if origin == "" {
		return Instance{}, fmt.Errorf("%w: [%s] origin", ErrMissingConfig, ConfigSection)
	}

	// "" covers both halves of what this used to report separately — an origin
	// that does not parse as a URL and one that parses to no host, such as a
	// scheme-less "spec.srht.bigb.es", which is a path. Neither can name the
	// domain of a mailbox, and the operator's fix is the same line either way.
	host := instconf.OriginHost(origin)
	if host == "" {
		return Instance{}, fmt.Errorf("%w: [%s] origin %q names no host",
			ErrMissingConfig, ConfigSection, origin)
	}

	inst := Instance{
		OwnerName:  strings.TrimPrefix(ownerName, "~"),
		OwnerEmail: ownerEmail,
		AgentEmail: agentLocalPart + "@" + host,
	}
	if err := inst.Validate(); err != nil {
		return Instance{}, err
	}
	return inst, nil
}

// Validate reports whether the instance identities are usable in a git
// signature line.
func (i Instance) Validate() error {
	if err := core.ValidateOwner(i.OwnerName); err != nil {
		return fmt.Errorf("%w: [sr.ht] owner-name: %v", ErrMissingConfig, err)
	}
	if err := validateSigField("[sr.ht] owner-email", i.OwnerEmail, MaxAgentLen); err != nil {
		return fmt.Errorf("%w: %v", ErrMissingConfig, err)
	}
	if err := validateSigField("agent email", i.AgentEmail, MaxAgentLen); err != nil {
		return fmt.Errorf("%w: %v", ErrMissingConfig, err)
	}
	return nil
}

// OwnerSignature is the human this instance belongs to: the committer of every
// agent write and of every merge commit.
func (i Instance) OwnerSignature() Signature {
	return Signature{Name: i.OwnerName, Email: i.OwnerEmail}
}

// AgentWrite is the provenance an agent must supply with every write. All three
// fields are mandatory — see Validate.
type AgentWrite struct {
	// Agent is the agent identity string, e.g. "claude-code/spec-writer".
	Agent string

	// Session is the agent's session ID, e.g. a UUID.
	Session string

	// Base is the approved-head revision the agent read the document at — the
	// If-Match value, and the same value that becomes the proposal's base B.
	// Recorded as X-Agent-Base so the claim is auditable against a pinned
	// ?rev= read rather than decorative.
	Base string
}

// Validate enforces that an agent write carries complete, usable provenance.
//
// Missing fields are rejected, never defaulted. The design is explicit that
// agent identity and session ID are mandatory on every write, and a commit
// stamped with a synthesised session is worse than a rejected write: it
// launders unattributable output as attributed, which is the one failure the
// whole provenance mechanism exists to prevent. Base is held to the same
// standard for the same reason — an empty X-Agent-Base trailer is a claim with
// nothing behind it.
//
// The character rules are not cosmetic. A newline in the agent string would
// break the git author line in two; a newline in the session would inject an
// arbitrary extra trailer; angle brackets would forge the mailbox. All three
// are rejected outright rather than escaped, because there is no legitimate
// agent name that needs them.
func (w AgentWrite) Validate() error {
	if w.Agent == "" {
		return fmt.Errorf("%w: agent identity is required on every agent write", ErrMissingProvenance)
	}
	if w.Session == "" {
		return fmt.Errorf("%w: agent session id is required on every agent write", ErrMissingProvenance)
	}
	if w.Base == "" {
		return fmt.Errorf("%w: base revision is required on every agent write", ErrMissingProvenance)
	}
	if err := validateSigField("agent identity", w.Agent, MaxAgentLen); err != nil {
		return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
	}
	if err := validateSigField("agent session id", w.Session, MaxSessionLen); err != nil {
		return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
	}
	if err := validateRev(w.Base); err != nil {
		return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
	}
	return nil
}

// Provenance is the fully-resolved authorship of one agent commit: who git will
// record as author and committer, and the trailers that carry the rest.
type Provenance struct {
	Author    Signature
	Committer Signature
	Session   string
	Base      string
}

// Provenance builds the authorship of an agent commit, per the design:
//
//	Author:    claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
//	Committer: bigbes <bigbes@gmail.com>
//
// The author is the agent, annotated with the human it acted for; the committer
// is the instance owner, because the service — running as bigbes — is what
// actually wrote the object. An invalid or incomplete AgentWrite is an error,
// never a commit with a hole in it.
func (i Instance) Provenance(w AgentWrite) (Provenance, error) {
	if err := i.Validate(); err != nil {
		return Provenance{}, err
	}
	if err := w.Validate(); err != nil {
		return Provenance{}, err
	}
	return Provenance{
		Author: Signature{
			Name:  w.Agent + " (for " + i.OwnerName + ")",
			Email: i.AgentEmail,
		},
		Committer: i.OwnerSignature(),
		Session:   w.Session,
		Base:      w.Base,
	}, nil
}

// TrailerBlock renders the trailers as their own paragraph, each line
// newline-terminated:
//
//	X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
//	X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809
func (p Provenance) TrailerBlock() string {
	var b strings.Builder
	b.WriteString(TrailerAgentSession)
	b.WriteString(": ")
	b.WriteString(p.Session)
	b.WriteByte('\n')
	b.WriteString(TrailerAgentBase)
	b.WriteString(": ")
	b.WriteString(p.Base)
	b.WriteByte('\n')
	return b.String()
}

// CommitMessage appends the trailer block to an agent-supplied message,
// separated by a blank line so git parses it as the trailer paragraph — and so
// that anything trailer-shaped inside the agent's own text stays part of the
// body rather than becoming the last block.
//
// An empty message is rejected: a commit whose only content is provenance
// records that something happened without saying what.
func (p Provenance) CommitMessage(message string) (string, error) {
	msg := strings.TrimRight(message, " \t\r\n")
	if msg == "" {
		return "", fmt.Errorf("%w: empty commit message", ErrInvalidProvenance)
	}
	return msg + "\n\n" + p.TrailerBlock(), nil
}

// validateSigField holds the rules shared by every string that ends up inside a
// git identity or trailer line: present, trimmed, bounded, valid UTF-8, and
// free of the bytes that would let it escape its line or its field.
func validateSigField(kind, s string, maxLen int) error {
	if s == "" {
		return fmt.Errorf("%s is empty", kind)
	}
	if len(s) > maxLen {
		return fmt.Errorf("%s is too long (%d > %d)", kind, len(s), maxLen)
	}
	if !utf8.ValidString(s) {
		return fmt.Errorf("%s is not valid UTF-8", kind)
	}
	if strings.TrimSpace(s) != s {
		return fmt.Errorf("%s %q has leading or trailing whitespace", kind, s)
	}
	for _, r := range s {
		switch {
		case r < 0x20 || r == 0x7f:
			return fmt.Errorf("%s %q contains a control character %U", kind, s, r)
		case r == '<' || r == '>':
			return fmt.Errorf("%s %q contains %q", kind, s, string(r))
		}
	}
	return nil
}

// validateRev reports whether s is a plausible git object name. Strict enough
// that nothing can be smuggled into the trailer line, loose enough to accept
// both an abbreviated name and a full sha-256 one.
func validateRev(s string) error {
	if len(s) < minRevLen || len(s) > maxRevLen {
		return fmt.Errorf("base revision %q must be %d-%d hex characters, got %d",
			s, minRevLen, maxRevLen, len(s))
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
			continue
		}
		return fmt.Errorf("base revision %q contains a non-hex byte %q", s, c)
	}
	return nil
}