coverage~bigbes/sr-ht-spec3cb1c03dauthn/provenance.go

Coverage
100.0% 78/78 statements
Δ
Blob
66b9c4f
Uncovered nothing — every instrumented line ran
1 package authn
2
3 import (
4 "fmt"
5 "strings"
6 "unicode/utf8"
7
8 "github.com/vaughan0/go-ini"
9 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
10
11 "sourcecraft.dev/bigbes/sr-ht-spec/core"
12 )
13
14 // ConfigSection is our config section. The literal ".sr.ht" suffix is what puts
15 // us in the nav network list and what other services look us up by, so it is a
16 // constant rather than a parameter.
17 const ConfigSection = "spec.sr.ht"
18
19 // Trailer keys recorded on every agent commit. Git trailers rather than a
20 // Postgres-only audit table, so provenance is visible in a plain `git log` on
21 // any clone and cannot drift from the content it describes.
22 //
23 // Only these two. The agent's own identity rides on the Author line, where git
24 // already puts "who wrote this", and duplicating it into a third trailer would
25 // create two spellings that can disagree.
26 const (
27 TrailerAgentSession = "X-Agent-Session"
28 TrailerAgentBase = "X-Agent-Base"
29 )
30
31 // agentLocalPart is the mailbox of the synthetic address stamped on agent
32 // authorship. Agents have no mailbox; the address exists because git demands
33 // one, and it is made obviously non-human so nobody mails it.
34 const agentLocalPart = "agent"
35
36 const (
37 // MaxAgentLen bounds the agent identity string. It becomes a git author
38 // name, which is read by humans in a review UI.
39 MaxAgentLen = 128
40
41 // MaxSessionLen bounds the session ID. A UUID is 36 bytes; the slack is for
42 // runners that prefix their own job identifiers.
43 MaxSessionLen = 128
44
45 // minRevLen and maxRevLen bound a git object name in the X-Agent-Base
46 // trailer: an abbreviated sha at the low end, a full sha-256 at the high.
47 minRevLen = 7
48 maxRevLen = 64
49 )
50
51 // Signature is a git identity: the name and mailbox halves of an author or
52 // committer line.
53 type Signature struct {
54 Name string
55 Email string
56 }
57
58 // String renders the identity in git's own "Name <email>" form.
59 3 func (s Signature) String() string { return s.Name + " <" + s.Email + ">" }
60
61 // Instance carries the identity facts an agent commit needs from the instance
62 // config. It is a value, so service/ can build it once at startup and hand
63 // copies around; every field is exported so a caller that has these facts from
64 // somewhere other than an ini file can construct it directly.
65 type Instance struct {
66 // OwnerName and OwnerEmail are [sr.ht] owner-name / owner-email — the human
67 // this instance belongs to. They become the committer of every agent write
68 // and of every merge, which is what makes "an agent proposed it, bigbes'
69 // service committed it" legible in `git log`.
70 OwnerName string
71 OwnerEmail string
72
73 // AgentEmail is the synthetic mailbox stamped on agent authorship.
74 AgentEmail string
75 }
76
77 // InstanceFromConfig reads the provenance identities out of the instance
78 // config.
79 //
80 // It mirrors config.GetOwner without the panic: this is a library, and a
81 // missing key should fail the daemon's startup validation with a message that
82 // names the key, not unwind a request. Every failure wraps ErrMissingConfig.
83 //
84 // AgentEmail is derived as agent@<host of [spec.sr.ht] origin>, so no new
85 // config key exists to forget or to disagree with the origin. The design's
86 // worked example shows agent@srht.bigb.es (the bare cookie domain) rather than
87 // agent@spec.srht.bigb.es; the design never says where that address comes from,
88 // and deriving it from our own origin is the only rule that needs no operator
89 // input. A caller that wants the bare domain sets Instance.AgentEmail directly.
90 //
91 // The host is instconf.OriginHost and deliberately not instconf.OriginAuthority,
92 // whose doc names a synthesized email domain among its callers: the design pins
93 // this address at "agent@<host of [spec.sr.ht] origin>", and a port in the
94 // domain half — agent@localhost:5091 on a development instance — is not a
95 // mailbox. The port distinguishes two endpoints, which is what an audience needs
96 // and what an address nobody may mail does not.
97 15 func InstanceFromConfig(conf ini.File) (Instance, error) {
98 15 ownerName, ok := conf.Get("sr.ht", "owner-name")
99 15 if !ok {
100 1 return Instance{}, fmt.Errorf("%w: [sr.ht] owner-name", ErrMissingConfig)
101 1 }
102 14 ownerEmail, ok := conf.Get("sr.ht", "owner-email")
103 14 if !ok {
104 1 return Instance{}, fmt.Errorf("%w: [sr.ht] owner-email", ErrMissingConfig)
105 1 }
106 13 origin := instconf.ExternalOrigin(conf, ConfigSection)
107 13 if origin == "" {
108 1 return Instance{}, fmt.Errorf("%w: [%s] origin", ErrMissingConfig, ConfigSection)
109 1 }
110
111 // "" covers both halves of what this used to report separately — an origin
112 // that does not parse as a URL and one that parses to no host, such as a
113 // scheme-less "spec.srht.bigb.es", which is a path. Neither can name the
114 // domain of a mailbox, and the operator's fix is the same line either way.
115 12 host := instconf.OriginHost(origin)
116 12 if host == "" {
117 2 return Instance{}, fmt.Errorf("%w: [%s] origin %q names no host",
118 2 ErrMissingConfig, ConfigSection, origin)
119 2 }
120
121 10 inst := Instance{
122 10 OwnerName: strings.TrimPrefix(ownerName, "~"),
123 10 OwnerEmail: ownerEmail,
124 10 AgentEmail: agentLocalPart + "@" + host,
125 10 }
126 10 if err := inst.Validate(); err != nil {
127 3 return Instance{}, err
128 3 }
129 7 return inst, nil
130 }
131
132 // Validate reports whether the instance identities are usable in a git
133 // signature line.
134 49 func (i Instance) Validate() error {
135 49 if err := core.ValidateOwner(i.OwnerName); err != nil {
136 5 return fmt.Errorf("%w: [sr.ht] owner-name: %v", ErrMissingConfig, err)
137 5 }
138 44 if err := validateSigField("[sr.ht] owner-email", i.OwnerEmail, MaxAgentLen); err != nil {
139 5 return fmt.Errorf("%w: %v", ErrMissingConfig, err)
140 5 }
141 39 if err := validateSigField("agent email", i.AgentEmail, MaxAgentLen); err != nil {
142 5 return fmt.Errorf("%w: %v", ErrMissingConfig, err)
143 5 }
144 34 return nil
145 }
146
147 // OwnerSignature is the human this instance belongs to: the committer of every
148 // agent write and of every merge commit.
149 7 func (i Instance) OwnerSignature() Signature {
150 7 return Signature{Name: i.OwnerName, Email: i.OwnerEmail}
151 7 }
152
153 // AgentWrite is the provenance an agent must supply with every write. All three
154 // fields are mandatory — see Validate.
155 type AgentWrite struct {
156 // Agent is the agent identity string, e.g. "claude-code/spec-writer".
157 Agent string
158
159 // Session is the agent's session ID, e.g. a UUID.
160 Session string
161
162 // Base is the approved-head revision the agent read the document at — the
163 // If-Match value, and the same value that becomes the proposal's base B.
164 // Recorded as X-Agent-Base so the claim is auditable against a pinned
165 // ?rev= read rather than decorative.
166 Base string
167 }
168
169 // Validate enforces that an agent write carries complete, usable provenance.
170 //
171 // Missing fields are rejected, never defaulted. The design is explicit that
172 // agent identity and session ID are mandatory on every write, and a commit
173 // stamped with a synthesised session is worse than a rejected write: it
174 // launders unattributable output as attributed, which is the one failure the
175 // whole provenance mechanism exists to prevent. Base is held to the same
176 // standard for the same reason — an empty X-Agent-Base trailer is a claim with
177 // nothing behind it.
178 //
179 // The character rules are not cosmetic. A newline in the agent string would
180 // break the git author line in two; a newline in the session would inject an
181 // arbitrary extra trailer; angle brackets would forge the mailbox. All three
182 // are rejected outright rather than escaped, because there is no legitimate
183 // agent name that needs them.
184 57 func (w AgentWrite) Validate() error {
185 57 if w.Agent == "" {
186 7 return fmt.Errorf("%w: agent identity is required on every agent write", ErrMissingProvenance)
187 7 }
188 50 if w.Session == "" {
189 4 return fmt.Errorf("%w: agent session id is required on every agent write", ErrMissingProvenance)
190 4 }
191 46 if w.Base == "" {
192 2 return fmt.Errorf("%w: base revision is required on every agent write", ErrMissingProvenance)
193 2 }
194 44 if err := validateSigField("agent identity", w.Agent, MaxAgentLen); err != nil {
195 12 return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
196 12 }
197 32 if err := validateSigField("agent session id", w.Session, MaxSessionLen); err != nil {
198 10 return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
199 10 }
200 22 if err := validateRev(w.Base); err != nil {
201 12 return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
202 12 }
203 10 return nil
204 }
205
206 // Provenance is the fully-resolved authorship of one agent commit: who git will
207 // record as author and committer, and the trailers that carry the rest.
208 type Provenance struct {
209 Author Signature
210 Committer Signature
211 Session string
212 Base string
213 }
214
215 // Provenance builds the authorship of an agent commit, per the design:
216 //
217 // Author: claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
218 // Committer: bigbes <bigbes@gmail.com>
219 //
220 // The author is the agent, annotated with the human it acted for; the committer
221 // is the instance owner, because the service — running as bigbes — is what
222 // actually wrote the object. An invalid or incomplete AgentWrite is an error,
223 // never a commit with a hole in it.
224 33 func (i Instance) Provenance(w AgentWrite) (Provenance, error) {
225 33 if err := i.Validate(); err != nil {
226 6 return Provenance{}, err
227 6 }
228 27 if err := w.Validate(); err != nil {
229 21 return Provenance{}, err
230 21 }
231 6 return Provenance{
232 6 Author: Signature{
233 6 Name: w.Agent + " (for " + i.OwnerName + ")",
234 6 Email: i.AgentEmail,
235 6 },
236 6 Committer: i.OwnerSignature(),
237 6 Session: w.Session,
238 6 Base: w.Base,
239 6 }, nil
240 }
241
242 // TrailerBlock renders the trailers as their own paragraph, each line
243 // newline-terminated:
244 //
245 // X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
246 // X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809
247 3 func (p Provenance) TrailerBlock() string {
248 3 var b strings.Builder
249 3 b.WriteString(TrailerAgentSession)
250 3 b.WriteString(": ")
251 3 b.WriteString(p.Session)
252 3 b.WriteByte('\n')
253 3 b.WriteString(TrailerAgentBase)
254 3 b.WriteString(": ")
255 3 b.WriteString(p.Base)
256 3 b.WriteByte('\n')
257 3 return b.String()
258 3 }
259
260 // CommitMessage appends the trailer block to an agent-supplied message,
261 // separated by a blank line so git parses it as the trailer paragraph — and so
262 // that anything trailer-shaped inside the agent's own text stays part of the
263 // body rather than becoming the last block.
264 //
265 // An empty message is rejected: a commit whose only content is provenance
266 // records that something happened without saying what.
267 5 func (p Provenance) CommitMessage(message string) (string, error) {
268 5 msg := strings.TrimRight(message, " \t\r\n")
269 5 if msg == "" {
270 3 return "", fmt.Errorf("%w: empty commit message", ErrInvalidProvenance)
271 3 }
272 2 return msg + "\n\n" + p.TrailerBlock(), nil
273 }
274
275 // validateSigField holds the rules shared by every string that ends up inside a
276 // git identity or trailer line: present, trimmed, bounded, valid UTF-8, and
277 // free of the bytes that would let it escape its line or its field.
278 159 func validateSigField(kind, s string, maxLen int) error {
279 159 if s == "" {
280 4 return fmt.Errorf("%s is empty", kind)
281 4 }
282 155 if len(s) > maxLen {
283 5 return fmt.Errorf("%s is too long (%d > %d)", kind, len(s), maxLen)
284 5 }
285 150 if !utf8.ValidString(s) {
286 4 return fmt.Errorf("%s is not valid UTF-8", kind)
287 4 }
288 146 if strings.TrimSpace(s) != s {
289 2 return fmt.Errorf("%s %q has leading or trailing whitespace", kind, s)
290 2 }
291 1768 for _, r := range s {
292 1768 switch {
293 8 case r < 0x20 || r == 0x7f:
294 8 return fmt.Errorf("%s %q contains a control character %U", kind, s, r)
295 9 case r == '<' || r == '>':
296 9 return fmt.Errorf("%s %q contains %q", kind, s, string(r))
297 }
298 }
299 127 return nil
300 }
301
302 // validateRev reports whether s is a plausible git object name. Strict enough
303 // that nothing can be smuggled into the trailer line, loose enough to accept
304 // both an abbreviated name and a full sha-256 one.
305 22 func validateRev(s string) error {
306 22 if len(s) < minRevLen || len(s) > maxRevLen {
307 4 return fmt.Errorf("base revision %q must be %d-%d hex characters, got %d",
308 4 s, minRevLen, maxRevLen, len(s))
309 4 }
310 489 for i := 0; i < len(s); i++ {
311 489 c := s[i]
312 489 if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
313 481 continue
314 }
315 8 return fmt.Errorf("base revision %q contains a non-hex byte %q", s, c)
316 }
317 10 return nil
318 }