molecule-ai-plugin-gh-identity/internal/ghidentity/config.go
Hongming Wang 4fd5ac7be3
Some checks failed
CI / Shellcheck + wrapper tests (push) Failing after 9s
CI / Go build + test + vet (push) Failing after 13m36s
feat(plugin): gh-identity — per-agent attribution via env injection + gh wrapper
Fixes molecule-core#1957: agent identity collapse where all agents share
one GitHub PAT and their writes attribute to the CEO.

This plugin takes the pragmatic "wrap, don't multiply identities" path:
- Injects MOLECULE_AGENT_ROLE / OWNER / ATTRIBUTION_BADGE per workspace
- Ships a shell wrapper for `gh` that:
  * prepends an attribution badge to issue/PR bodies on publish
  * rewrites --assignee @me to the role's designated human owner
  * emits an NDJSON audit log to /var/log/molecule-gh.ndjson
- Wrapper is shipped as base64 env var; each workspace template's
  install.sh decodes and writes it to /usr/local/bin/gh

Scales where GitHub Apps / machine users don't: adding a new agent role
is one entry in config.yaml, not a GitHub UI roundtrip per role.

See README + known-issues.md for the v2-architecture migration plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:38:45 -07:00

102 lines
3.1 KiB
Go

// Package ghidentity implements the workspace-server plugin that injects
// per-agent attribution env vars into workspace containers.
//
// See repo README for the "why" (molecule-core#1957 agent-identity
// collapse). This package contains the wiring; the behavioural logic
// lives in wrapper.sh which is shipped to the workspace via env.
package ghidentity
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Config maps agent roles (set per-workspace via MOLECULE_AGENT_ROLE)
// to the human GitHub user who "owns" that role for purposes of @me
// rewriting. A single default entry covers roles not explicitly listed.
//
// Config is loaded once at platform boot from
// $MOLECULE_GH_IDENTITY_CONFIG_FILE. Missing file → use the DefaultOwner
// for all roles; plugin still works, just with blanket attribution.
type Config struct {
Roles map[string]RoleConfig `yaml:"roles"`
}
// RoleConfig defines the per-role settings. Today: just the owner.
// Future fields (capability overrides, rate limits, per-role repo
// allowlists) slot in here without breaking the surface.
type RoleConfig struct {
Owner string `yaml:"owner"`
}
// LoadConfig reads a YAML config file. Missing file is not an error —
// returns a Config with an empty Roles map and the caller falls through
// to DefaultOwner.
func LoadConfig(path string) (*Config, error) {
if path == "" {
return &Config{Roles: map[string]RoleConfig{}}, nil
}
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Config{Roles: map[string]RoleConfig{}}, nil
}
return nil, fmt.Errorf("read config %q: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
if cfg.Roles == nil {
cfg.Roles = map[string]RoleConfig{}
}
return &cfg, nil
}
// ResolveOwner picks the GitHub owner for the given role. Unknown roles
// fall through to the "default" entry; if neither is set, returns "" so
// the wrapper strips --assignee @me entirely (correct behavior — better
// than assigning to the wrong person).
//
// Lookup is case-insensitive against the sanitized role form. The
// yaml config writer might use "PMM-Lead", "pmm-lead", or "Pmm-Lead"
// interchangeably — we accept all three by lower-casing both sides.
// "default" is treated literally (reserved key).
func (c *Config) ResolveOwner(role string) string {
needle := strings.ToLower(role)
for k, rc := range c.Roles {
if k == "default" {
continue
}
if strings.ToLower(k) == needle && rc.Owner != "" {
return rc.Owner
}
}
if rc, ok := c.Roles["default"]; ok {
return rc.Owner
}
return ""
}
// SanitizeRole normalizes a role string for use in env vars / badges.
// Strips whitespace, upper-cases the first letter of each hyphen-
// separated segment so arbitrary user input (" pmm-lead ") becomes a
// predictable string ("PMM-Lead") visible in attribution badges.
func SanitizeRole(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parts := strings.Split(raw, "-")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, "-")
}