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 " 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@, 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@", 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) // Committer: bigbes // // 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 }