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
}