Files
teabot/internal/config/config.go
T
unkinben 748048be50
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add fail-closed author allowlist gating job dispatch
Sessions run claude with --dangerously-skip-permissions and a prompt built
from issue/PR/comment text, so only trusted authors may supply that text.
teabot now dispatches a job only when the triggering event's author login is
on an allowlist; an empty allowlist dispatches nothing (fail-closed).

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 00:33:09 +10:00

340 lines
11 KiB
Go

// Package config loads and validates teabot's daemon configuration and the
// per-personality tea config files it references.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
// AppName is used for XDG config/state directory names.
AppName = "teabot"
// DefaultGiteaURL is the Gitea instance teabot watches by default.
DefaultGiteaURL = "https://git.unkin.net"
// DefaultJobImage already ships claude-code plus a Go/Node/Python/tea
// developer toolchain, so teabot reuses it instead of building its own.
DefaultJobImage = "git.unkin.net/unkin/agent-dev:latest"
// DefaultPollInterval is how often each repo is polled when unset.
DefaultPollInterval = 60 * time.Second
// DefaultJobTimeout bounds a single Claude session.
DefaultJobTimeout = 30 * time.Minute
// DefaultMaxConcurrent caps simultaneously running job containers.
DefaultMaxConcurrent = 2
// DefaultContainerHome is the home directory inside the job image
// (the agent-dev image runs as the unprivileged "agent" user).
DefaultContainerHome = "/home/agent"
)
// Role describes what work a personality is allowed to perform.
type Role string
const (
// RoleImplementer handles new issues and issue-comment follow-ups.
RoleImplementer Role = "implementer"
// RoleReviewer handles new pull requests and PR-comment follow-ups.
RoleReviewer Role = "reviewer"
// RoleBoth handles every event kind.
RoleBoth Role = "both"
)
// Personality is a distinct Gitea bot identity backed by its own tea config
// file. Different personalities let, for example, an "implementer" account open
// PRs while a separate "reviewer" account critiques them.
type Personality struct {
// Name is the human-readable label used in logs and prompts.
Name string `yaml:"name"`
// TeaConfig is the path to a tea config.yml holding this bot's Gitea
// login (token + url + username). teabot parses it for the API token and
// mounts it into the job container so tea acts as this identity.
TeaConfig string `yaml:"tea_config"`
// Role gates which event kinds this personality reacts to.
Role Role `yaml:"role"`
// GitName / GitEmail set the commit identity inside the container.
GitName string `yaml:"git_name"`
GitEmail string `yaml:"git_email"`
// Login is populated at load time from the parsed tea config: the Gitea
// username. Events authored by any personality's login are ignored so the
// bot never reacts to its own comments (loop prevention).
Login string `yaml:"-"`
// Token is populated at load time from the parsed tea config: the API
// token used for polling as this identity. Never written back to disk.
Token string `yaml:"-"`
// URL is populated at load time from the parsed tea config: the instance
// URL of this login.
URL string `yaml:"-"`
}
// CanImplement reports whether the personality reacts to issue events.
func (p Personality) CanImplement() bool { return p.Role == RoleImplementer || p.Role == RoleBoth }
// CanReview reports whether the personality reacts to pull-request events.
func (p Personality) CanReview() bool { return p.Role == RoleReviewer || p.Role == RoleBoth }
// Config is teabot's top-level configuration (~/.config/teabot/config.yaml).
type Config struct {
// GiteaURL is the base URL of the Gitea instance to poll.
GiteaURL string `yaml:"gitea_url"`
// Repos is the list of owner/name repositories to watch.
Repos []string `yaml:"repos"`
// PollInterval is the delay between poll cycles.
PollInterval time.Duration `yaml:"poll_interval"`
// StateDir overrides the XDG state directory used to persist processed
// events. Empty means $XDG_STATE_HOME/teabot (default ~/.local/state/teabot).
StateDir string `yaml:"state_dir"`
// MaxConcurrent caps simultaneously running job containers.
MaxConcurrent int `yaml:"max_concurrent"`
// JobTimeout bounds a single dispatched Claude session.
JobTimeout time.Duration `yaml:"job_timeout"`
// JobImage is the container image each session runs in.
JobImage string `yaml:"job_image"`
// ContainerHome is the home directory inside JobImage that mounts target.
ContainerHome string `yaml:"container_home"`
// ClaudeConfigDir is the host directory holding Claude Code credentials
// (subscription auth), mounted into each container. Empty means ~/.claude.
ClaudeConfigDir string `yaml:"claude_config_dir"`
// AnthropicAPIKey, when set, is injected as ANTHROPIC_API_KEY instead of
// relying on the mounted subscription credentials.
AnthropicAPIKey string `yaml:"anthropic_api_key"`
// AnthropicBaseURL, when set, is injected as ANTHROPIC_BASE_URL.
AnthropicBaseURL string `yaml:"anthropic_base_url"`
// AllowedAuthors is the global allowlist of Gitea usernames whose issues,
// PRs, and comments may trigger a Claude job. This is a SECURITY control:
// jobs run with --dangerously-skip-permissions in a container whose prompt
// is built from event text, so only trusted authors may supply that text.
// An empty/absent allowlist is fail-closed — nothing is dispatched.
AllowedAuthors []string `yaml:"allowed_authors"`
// RepoAllowedAuthors optionally overrides AllowedAuthors per repository
// (keyed by "owner/name"). A present entry fully replaces the global list
// for that repo (even when empty, which disables dispatch for it); an
// absent entry falls back to AllowedAuthors.
RepoAllowedAuthors map[string][]string `yaml:"repo_allowed_authors"`
// Personalities are the bot identities teabot dispatches as.
Personalities []Personality `yaml:"personalities"`
}
// Load reads and validates the config at path, applying defaults and resolving
// each personality's tea config into a token/login/url.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config %s: %w", path, err)
}
cfg.applyDefaults()
if err := cfg.resolvePersonalities(); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) applyDefaults() {
if c.GiteaURL == "" {
c.GiteaURL = DefaultGiteaURL
}
c.GiteaURL = strings.TrimRight(c.GiteaURL, "/")
if c.PollInterval <= 0 {
c.PollInterval = DefaultPollInterval
}
if c.MaxConcurrent <= 0 {
c.MaxConcurrent = DefaultMaxConcurrent
}
if c.JobTimeout <= 0 {
c.JobTimeout = DefaultJobTimeout
}
if c.JobImage == "" {
c.JobImage = DefaultJobImage
}
if c.ContainerHome == "" {
c.ContainerHome = DefaultContainerHome
}
if c.ClaudeConfigDir == "" {
if home, err := os.UserHomeDir(); err == nil {
c.ClaudeConfigDir = filepath.Join(home, ".claude")
}
} else {
c.ClaudeConfigDir = expandHome(c.ClaudeConfigDir)
}
if c.StateDir != "" {
c.StateDir = expandHome(c.StateDir)
}
for i := range c.Personalities {
if c.Personalities[i].Role == "" {
c.Personalities[i].Role = RoleBoth
}
c.Personalities[i].TeaConfig = expandHome(c.Personalities[i].TeaConfig)
}
}
// resolvePersonalities parses each personality's tea config file and fills in
// its token, login, and instance URL.
func (c *Config) resolvePersonalities() error {
for i := range c.Personalities {
p := &c.Personalities[i]
if p.TeaConfig == "" {
return fmt.Errorf("personality %q: tea_config is required", p.Name)
}
login, err := ParseTeaConfig(p.TeaConfig, c.GiteaURL)
if err != nil {
return fmt.Errorf("personality %q: %w", p.Name, err)
}
p.Login = login.User
p.Token = login.Token
p.URL = login.URL
}
return nil
}
// Validate checks the config is internally consistent and usable.
func (c *Config) Validate() error {
if len(c.Repos) == 0 {
return fmt.Errorf("no repos configured")
}
for _, r := range c.Repos {
if !strings.Contains(strings.Trim(r, "/"), "/") {
return fmt.Errorf("repo %q must be in owner/name form", r)
}
}
if len(c.Personalities) == 0 {
return fmt.Errorf("at least one personality is required")
}
seen := map[string]bool{}
var haveImpl, haveReview bool
for _, p := range c.Personalities {
if p.Name == "" {
return fmt.Errorf("personality with empty name")
}
if seen[p.Name] {
return fmt.Errorf("duplicate personality name %q", p.Name)
}
seen[p.Name] = true
switch p.Role {
case RoleImplementer, RoleReviewer, RoleBoth:
default:
return fmt.Errorf("personality %q: invalid role %q", p.Name, p.Role)
}
if p.Token == "" {
return fmt.Errorf("personality %q: no token found in tea config", p.Name)
}
if p.Login == "" {
return fmt.Errorf("personality %q: no username found in tea config", p.Name)
}
haveImpl = haveImpl || p.CanImplement()
haveReview = haveReview || p.CanReview()
}
if !haveImpl {
return fmt.Errorf("no personality can implement (role implementer or both)")
}
if !haveReview {
return fmt.Errorf("no personality can review (role reviewer or both)")
}
return nil
}
// BotLogins returns the set of Gitea usernames belonging to configured
// personalities, used to skip events the bot authored itself.
func (c *Config) BotLogins() map[string]bool {
m := make(map[string]bool, len(c.Personalities))
for _, p := range c.Personalities {
if p.Login != "" {
m[p.Login] = true
}
}
return m
}
// AllowedAuthorsFor returns the effective author allowlist for a repo: the
// per-repo override when one is configured (even if empty), otherwise the
// global list.
func (c *Config) AllowedAuthorsFor(repo string) []string {
if c.RepoAllowedAuthors != nil {
if v, ok := c.RepoAllowedAuthors[repo]; ok {
return v
}
}
return c.AllowedAuthors
}
// IsAuthorAllowed reports whether login may trigger a job in repo. Matching is
// case-insensitive (Gitea usernames are unique case-insensitively). An empty
// effective allowlist denies everyone (fail-closed).
func (c *Config) IsAuthorAllowed(repo, login string) bool {
if login == "" {
return false
}
for _, a := range c.AllowedAuthorsFor(repo) {
if strings.EqualFold(strings.TrimSpace(a), login) {
return true
}
}
return false
}
// HasAnyAllowlist reports whether any allowlist entry is configured anywhere.
// When false, teabot dispatches nothing (fail-closed) and warns at startup.
func (c *Config) HasAnyAllowlist() bool {
if len(c.AllowedAuthors) > 0 {
return true
}
for _, v := range c.RepoAllowedAuthors {
if len(v) > 0 {
return true
}
}
return false
}
// ImplementerFor returns the personality that should handle issue work, or nil.
func (c *Config) ImplementerFor() *Personality {
for i := range c.Personalities {
if c.Personalities[i].CanImplement() {
return &c.Personalities[i]
}
}
return nil
}
// ReviewerFor returns the personality that should handle PR work, or nil.
func (c *Config) ReviewerFor() *Personality {
for i := range c.Personalities {
if c.Personalities[i].CanReview() {
return &c.Personalities[i]
}
}
return nil
}
// expandHome expands a leading ~/ to the user's home directory.
func expandHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
if p == "~" {
return home
}
return filepath.Join(home, p[2:])
}
}
return p
}