1	package jira
2
3	import (
4		"context"
5		"encoding/json"
6		"fmt"
7		"io"
8		"log/slog"
9		"regexp"
10		"strings"
11		"time"
12
13		gojira "github.com/andygrunwald/go-jira/v2/cloud"
14	)
15
16	type Config struct {
17		URL            string        `toml:"url"`
18		Username       string        `toml:"username"`
19		APIToken       string        `toml:"api_token"`
20		Project        string        `toml:"project"`
21		IssueType      string        `toml:"issue_type"`
22		PollInterval   time.Duration `toml:"poll_interval"`
23		ApprovedStatus string        `toml:"approved_status"`
24		RejectedStatus string        `toml:"rejected_status"`
25	}
26
27	func (c *Config) ApplyDefaults() {
28		if c.IssueType == "" {
29			c.IssueType = "Task"
30		}
31		if c.PollInterval == 0 {
32			c.PollInterval = 30 * time.Second
33		}
34		if c.ApprovedStatus == "" {
35			c.ApprovedStatus = "Approved"
36		}
37		if c.RejectedStatus == "" {
38			c.RejectedStatus = "Rejected"
39		}
40	}
41
42	func (c *Config) Validate() error {
43		if c.URL == "" {
44			return fmt.Errorf("url is required")
45		}
46		if c.Project == "" {
47			return fmt.Errorf("project is required")
48		}
49		if c.Username == "" || c.APIToken == "" {
50			return fmt.Errorf("username and api_token are required")
51		}
52		return nil
53	}
54
55	type Status int
56
57	const (
58		StatusOpen Status = iota
59		StatusApproved
60		StatusRejected
61	)
62
63	func (s Status) String() string {
64		switch s {
65		case StatusApproved:
66			return "approved"
67		case StatusRejected:
68			return "rejected"
69		}
70		return "open"
71	}
72
73	type TicketInput struct {
74		RequestID   string
75		User        string
76		Roles       []string
77		Reason      string
78		Score       int
79		Tier        string
80		Components  map[string]int
81		AIReasoning string
82		Escalation  bool
83	}
84
85	// reqIDRE extracts a Teleport access-request ID from a Jira ticket
86	// description we created via buildDescription. The character class between
87	// the colon and the capture tolerates Jira wiki bold markers (e.g. the
88	// "*" that closes "*Teleport Request ID:*") as well as plain whitespace,
89	// so rehydration survives formatting tweaks to the description template.
90	var reqIDRE = regexp.MustCompile(`Teleport Request ID:[^A-Za-z0-9-]*([A-Za-z0-9-]+)`)
91
92	type Client struct {
93		cfg  Config
94		jira *gojira.Client
95	}
96
97	func New(cfg Config) (*Client, error) {
98		tp := gojira.BasicAuthTransport{Username: cfg.Username, APIToken: cfg.APIToken}
99		jc, err := gojira.NewClient(cfg.URL, tp.Client())
100		if err != nil {
101			return nil, err
102		}
103		return &Client{cfg: cfg, jira: jc}, nil
104	}
105
106	func (c *Client) CreateITSMTicket(ctx context.Context, in TicketInput) (string, error) {
107		if c.cfg.Project == "" {
108			return "", fmt.Errorf("jira project key is empty")
109		}
110		if c.cfg.IssueType == "" {
111			return "", fmt.Errorf("jira issue type is empty")
112		}
113		if in.RequestID == "" {
114			return "", fmt.Errorf("request id is empty")
115		}
116
117		labels := []string{"teleport", "teleport-access"}
118		if in.Tier != "" {
119			labels = append(labels, "tier-"+strings.ToLower(in.Tier))
120		}
121		if in.Escalation {
122			labels = append(labels, "teleport-escalation")
123		}
124
125		summary := fmt.Sprintf("[teleport] Access request %s - %s", in.RequestID, in.Tier)
126		if in.Escalation {
127			summary = fmt.Sprintf("[teleport][escalation] Session escalation %s - %s", in.RequestID, in.Tier)
128		}
129
130		issue := &gojira.Issue{
131			Fields: &gojira.IssueFields{
132				Project:     gojira.Project{Key: c.cfg.Project},
133				Type:        gojira.IssueType{Name: c.cfg.IssueType},
134				Summary:     summary,
135				Description: buildDescription(in),
136				Labels:      labels,
137			},
138		}
139
140		// Serialise the outgoing issue only when the debug log level is
141		// active; marshaling runs on every ticket creation otherwise and
142		// leaks request/requester detail to an unstructured stream.
143		if slog.Default().Enabled(ctx, slog.LevelDebug) {
144			reqBody, err := json.MarshalIndent(issue, "", "  ")
145			if err != nil {
146				return "", fmt.Errorf("marshal jira issue: %w", err)
147			}
148			slog.DebugContext(ctx, "creating jira issue",
149				"request_id", in.RequestID,
150				"project", c.cfg.Project,
151				"body", string(reqBody))
152		}
153
154		created, resp, err := c.jira.Issue.Create(ctx, issue)
155		if err != nil {
156			if resp != nil && resp.Body != nil && slog.Default().Enabled(ctx, slog.LevelDebug) {
157				body, _ := io.ReadAll(resp.Body)
158				slog.DebugContext(ctx, "jira error response",
159					"request_id", in.RequestID,
160					"status", resp.StatusCode,
161					"body", string(body))
162			}
163			return "", fmt.Errorf("create jira issue: %w", err)
164		}
165
166		if created == nil {
167			return "", fmt.Errorf("jira returned nil issue without error")
168		}
169
170		return created.Key, nil
171	}
172
173	func (c *Client) TicketStatus(ctx context.Context, key string) (Status, error) {
174		issue, _, err := c.jira.Issue.Get(ctx, key, nil)
175		if err != nil {
176			return StatusOpen, err
177		}
178		if issue.Fields == nil || issue.Fields.Status == nil {
179			return StatusOpen, nil
180		}
181		switch issue.Fields.Status.Name {
182		case c.cfg.ApprovedStatus:
183			return StatusApproved, nil
184		case c.cfg.RejectedStatus:
185			return StatusRejected, nil
186		}
187		return StatusOpen, nil
188	}
189
190	func (c *Client) ListOpenTeleportTickets(ctx context.Context) (map[string]string, error) {
191		jql := fmt.Sprintf(`project = %q AND labels = teleport AND status not in (%q, %q)`,
192			c.cfg.Project, c.cfg.ApprovedStatus, c.cfg.RejectedStatus)
193		issues, _, err := c.jira.Issue.Search(ctx, jql, nil)
194		if err != nil {
195			return nil, err
196		}
197		out := map[string]string{}
198		for _, iss := range issues {
199			if iss.Fields == nil {
200				continue
201			}
202			m := reqIDRE.FindStringSubmatch(iss.Fields.Description)
203			if len(m) < 2 {
204				continue
205			}
206			out[m[1]] = iss.Key
207		}
208		return out, nil
209	}
210
211	func buildDescription(in TicketInput) string {
212		var sb strings.Builder
213		if in.Escalation {
214			sb.WriteString("h2. Teleport Session Escalation Review\n\n")
215			sb.WriteString("{panel:title=Session escalation}This request is a mid-session privilege bump — please prioritise review.{panel}\n\n")
216		} else {
217			sb.WriteString("h2. Teleport Access Request Review\n\n")
218		}
219		fmt.Fprintf(&sb, "* *Teleport Request ID:* %s\n", in.RequestID)
220		fmt.Fprintf(&sb, "* *Requester:* %s\n", in.User)
221		fmt.Fprintf(&sb, "* *Roles requested:* %s\n", strings.Join(in.Roles, ", "))
222		fmt.Fprintf(&sb, "* *Risk score:* %d\n", in.Score)
223		fmt.Fprintf(&sb, "* *Risk tier:* %s\n\n", in.Tier)
224
225		sb.WriteString("h3. Request reason\n")
226		sb.WriteString("{code}\n")
227		sb.WriteString(in.Reason)
228		sb.WriteString("\n{code}\n\n")
229
230		if len(in.Components) > 0 {
231			sb.WriteString("h3. Risk components\n")
232			for k, v := range in.Components {
233				fmt.Fprintf(&sb, "* %s: +%d\n", k, v)
234			}
235			sb.WriteString("\n")
236		}
237
238		if in.AIReasoning != "" {
239			sb.WriteString("h3. AI reasoning\n")
240			sb.WriteString("{quote}\n")
241			sb.WriteString(in.AIReasoning)
242			sb.WriteString("\n{quote}\n\n")
243		}
244
245		sb.WriteString("h3. SRE action required\n")
246		sb.WriteString("Transition this ticket to the configured approved or rejected status to resolve the Teleport access request.\n")
247
248		return sb.String()
249	}
250

package jira

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"regexp"
	"strings"
	"time"

	gojira "github.com/andygrunwald/go-jira/v2/cloud"
)

type Config struct {
	URL            string        `toml:"url"`
	Username       string        `toml:"username"`
	APIToken       string        `toml:"api_token"`
	Project        string        `toml:"project"`
	IssueType      string        `toml:"issue_type"`
	PollInterval   time.Duration `toml:"poll_interval"`
	ApprovedStatus string        `toml:"approved_status"`
	RejectedStatus string        `toml:"rejected_status"`
}

func (c *Config) ApplyDefaults() {
	if c.IssueType == "" {
		c.IssueType = "Task"
	}
	if c.PollInterval == 0 {
		c.PollInterval = 30 * time.Second
	}
	if c.ApprovedStatus == "" {
		c.ApprovedStatus = "Approved"
	}
	if c.RejectedStatus == "" {
		c.RejectedStatus = "Rejected"
	}
}

func (c *Config) Validate() error {
	if c.URL == "" {
		return fmt.Errorf("url is required")
	}
	if c.Project == "" {
		return fmt.Errorf("project is required")
	}
	if c.Username == "" || c.APIToken == "" {
		return fmt.Errorf("username and api_token are required")
	}
	return nil
}

type Status int

const (
	StatusOpen Status = iota
	StatusApproved
	StatusRejected
)

func (s Status) String() string {
	switch s {
	case StatusApproved:
		return "approved"
	case StatusRejected:
		return "rejected"
	}
	return "open"
}

type TicketInput struct {
	RequestID   string
	User        string
	Roles       []string
	Reason      string
	Score       int
	Tier        string
	Components  map[string]int
	AIReasoning string
	Escalation  bool
}

// reqIDRE extracts a Teleport access-request ID from a Jira ticket
// description we created via buildDescription. The character class between
// the colon and the capture tolerates Jira wiki bold markers (e.g. the
// "*" that closes "*Teleport Request ID:*") as well as plain whitespace,
// so rehydration survives formatting tweaks to the description template.
var reqIDRE = regexp.MustCompile(`Teleport Request ID:[^A-Za-z0-9-]*([A-Za-z0-9-]+)`)

type Client struct {
	cfg  Config
	jira *gojira.Client
}

func New(cfg Config) (*Client, error) {
	tp := gojira.BasicAuthTransport{Username: cfg.Username, APIToken: cfg.APIToken}
	jc, err := gojira.NewClient(cfg.URL, tp.Client())
	if err != nil {
		return nil, err
	}
	return &Client{cfg: cfg, jira: jc}, nil
}

func (c *Client) CreateITSMTicket(ctx context.Context, in TicketInput) (string, error) {
	if c.cfg.Project == "" {
		return "", fmt.Errorf("jira project key is empty")
	}
	if c.cfg.IssueType == "" {
		return "", fmt.Errorf("jira issue type is empty")
	}
	if in.RequestID == "" {
		return "", fmt.Errorf("request id is empty")
	}

	labels := []string{"teleport", "teleport-access"}
	if in.Tier != "" {
		labels = append(labels, "tier-"+strings.ToLower(in.Tier))
	}
	if in.Escalation {
		labels = append(labels, "teleport-escalation")
	}

	summary := fmt.Sprintf("[teleport] Access request %s - %s", in.RequestID, in.Tier)
	if in.Escalation {
		summary = fmt.Sprintf("[teleport][escalation] Session escalation %s - %s", in.RequestID, in.Tier)
	}

	issue := &gojira.Issue{
		Fields: &gojira.IssueFields{
			Project:     gojira.Project{Key: c.cfg.Project},
			Type:        gojira.IssueType{Name: c.cfg.IssueType},
			Summary:     summary,
			Description: buildDescription(in),
			Labels:      labels,
		},
	}

	// Serialise the outgoing issue only when the debug log level is
	// active; marshaling runs on every ticket creation otherwise and
	// leaks request/requester detail to an unstructured stream.
	if slog.Default().Enabled(ctx, slog.LevelDebug) {
		reqBody, err := json.MarshalIndent(issue, "", "  ")
		if err != nil {
			return "", fmt.Errorf("marshal jira issue: %w", err)
		}
		slog.DebugContext(ctx, "creating jira issue",
			"request_id", in.RequestID,
			"project", c.cfg.Project,
			"body", string(reqBody))
	}

	created, resp, err := c.jira.Issue.Create(ctx, issue)
	if err != nil {
		if resp != nil && resp.Body != nil && slog.Default().Enabled(ctx, slog.LevelDebug) {
			body, _ := io.ReadAll(resp.Body)
			slog.DebugContext(ctx, "jira error response",
				"request_id", in.RequestID,
				"status", resp.StatusCode,
				"body", string(body))
		}
		return "", fmt.Errorf("create jira issue: %w", err)
	}

	if created == nil {
		return "", fmt.Errorf("jira returned nil issue without error")
	}

	return created.Key, nil
}

func (c *Client) TicketStatus(ctx context.Context, key string) (Status, error) {
	issue, _, err := c.jira.Issue.Get(ctx, key, nil)
	if err != nil {
		return StatusOpen, err
	}
	if issue.Fields == nil || issue.Fields.Status == nil {
		return StatusOpen, nil
	}
	switch issue.Fields.Status.Name {
	case c.cfg.ApprovedStatus:
		return StatusApproved, nil
	case c.cfg.RejectedStatus:
		return StatusRejected, nil
	}
	return StatusOpen, nil
}

func (c *Client) ListOpenTeleportTickets(ctx context.Context) (map[string]string, error) {
	jql := fmt.Sprintf(`project = %q AND labels = teleport AND status not in (%q, %q)`,
		c.cfg.Project, c.cfg.ApprovedStatus, c.cfg.RejectedStatus)
	issues, _, err := c.jira.Issue.Search(ctx, jql, nil)
	if err != nil {
		return nil, err
	}
	out := map[string]string{}
	for _, iss := range issues {
		if iss.Fields == nil {
			continue
		}
		m := reqIDRE.FindStringSubmatch(iss.Fields.Description)
		if len(m) < 2 {
			continue
		}
		out[m[1]] = iss.Key
	}
	return out, nil
}

func buildDescription(in TicketInput) string {
	var sb strings.Builder
	if in.Escalation {
		sb.WriteString("h2. Teleport Session Escalation Review\n\n")
		sb.WriteString("{panel:title=Session escalation}This request is a mid-session privilege bump — please prioritise review.{panel}\n\n")
	} else {
		sb.WriteString("h2. Teleport Access Request Review\n\n")
	}
	fmt.Fprintf(&sb, "* *Teleport Request ID:* %s\n", in.RequestID)
	fmt.Fprintf(&sb, "* *Requester:* %s\n", in.User)
	fmt.Fprintf(&sb, "* *Roles requested:* %s\n", strings.Join(in.Roles, ", "))
	fmt.Fprintf(&sb, "* *Risk score:* %d\n", in.Score)
	fmt.Fprintf(&sb, "* *Risk tier:* %s\n\n", in.Tier)

	sb.WriteString("h3. Request reason\n")
	sb.WriteString("{code}\n")
	sb.WriteString(in.Reason)
	sb.WriteString("\n{code}\n\n")

	if len(in.Components) > 0 {
		sb.WriteString("h3. Risk components\n")
		for k, v := range in.Components {
			fmt.Fprintf(&sb, "* %s: +%d\n", k, v)
		}
		sb.WriteString("\n")
	}

	if in.AIReasoning != "" {
		sb.WriteString("h3. AI reasoning\n")
		sb.WriteString("{quote}\n")
		sb.WriteString(in.AIReasoning)
		sb.WriteString("\n{quote}\n\n")
	}

	sb.WriteString("h3. SRE action required\n")
	sb.WriteString("Transition this ticket to the configured approved or rejected status to resolve the Teleport access request.\n")

	return sb.String()
}


analyze and understand this ruth project


1	// Package reputation tracks per-user reputation scores derived from the
2	// quality of Teleport access requests. State is persisted to a local JSON
3	// file so no external database is required.
4	package reputation
5
6	import (
7		"context"
8		"encoding/json"
9		"errors"
10		"fmt"
11		"io/fs"
12		"os"
13		"path/filepath"
14		"sync"
15	)
16
17	type Config struct {
18		PersistPath            string `toml:"persist_path"`
19		StartingScore          int    `toml:"starting_score"`
20		FloorScore             int    `toml:"floor_score"`
21		CeilingScore           int    `toml:"ceiling_score"`
22		PenaltyMissingCUSTX    int    `toml:"penalty_missing_custx"`
23		PenaltyThinDescription int    `toml:"penalty_thin_description"`
24	}
25
26	func (c *Config) ApplyDefaults() {
27		if c.StartingScore == 0 {
28			c.StartingScore = 100
29		}
30		if c.CeilingScore == 0 {
31			c.CeilingScore = 100
32		}
33		if c.PenaltyMissingCUSTX == 0 {
34			c.PenaltyMissingCUSTX = 10
35		}
36		if c.PenaltyThinDescription == 0 {
37			c.PenaltyThinDescription = 5
38		}
39	}
40
41	func (c *Config) Validate() error {
42		if c.FloorScore > c.CeilingScore {
43			return fmt.Errorf("floor_score (%d) must be <= ceiling_score (%d)", c.FloorScore, c.CeilingScore)
44		}
45		if c.StartingScore < c.FloorScore || c.StartingScore > c.CeilingScore {
46			return fmt.Errorf("starting_score (%d) must be within [%d, %d]", c.StartingScore, c.FloorScore, c.CeilingScore)
47		}
48		return nil
49	}
50
51	type Penalty struct {
52		Reason string
53		Amount int
54	}
55
56	type Assessment struct {
57		Score     int
58		Previous  int
59		Penalties []Penalty
60	}
61
62	type Store struct {
63		cfg Config
64		mu  sync.RWMutex
65		rep map[string]int
66	}
67
68	func NewStore(cfg Config) (*Store, error) {
69		s := &Store{cfg: cfg, rep: make(map[string]int, 64)}
70		if cfg.PersistPath != "" {
71			if err := s.load(); err != nil {
72				return nil, err
73			}
74		}
75		return s, nil
76	}
77
78	// Score returns the current reputation for user, or StartingScore if unseen.
79	func (s *Store) Score(user string) int {
80		s.mu.RLock()
81		defer s.mu.RUnlock()
82		if v, ok := s.rep[user]; ok {
83			return v
84		}
85		return s.cfg.StartingScore
86	}
87
88	// Range invokes fn for every tracked user under a read lock. Do not call
89	// back into the store from fn or you will deadlock.
90	func (s *Store) Range(fn func(user string, score int)) {
91		s.mu.RLock()
92		defer s.mu.RUnlock()
93		for u, sc := range s.rep {
94			fn(u, sc)
95		}
96	}
97
98	// Assess translates risk components into configured penalties, docks the
99	// user's score, clamps to [floor, ceiling], persists to disk, and returns
100	// the updated assessment. A component map with no bad signals is a no-op.
101	func (s *Store) Assess(ctx context.Context, user string, components map[string]int) (Assessment, error) {
102		return s.apply(ctx, user, s.penaltiesFor(components))
103	}
104
105	func (s *Store) penaltiesFor(components map[string]int) []Penalty {
106		penalties := make([]Penalty, 0, 2)
107		if _, ok := components["missing_custx_ticket"]; ok && s.cfg.PenaltyMissingCUSTX > 0 {
108			penalties = append(penalties, Penalty{Reason: "missing_custx_ticket", Amount: s.cfg.PenaltyMissingCUSTX})
109		}
110		if _, ok := components["thin_description"]; ok && s.cfg.PenaltyThinDescription > 0 {
111			penalties = append(penalties, Penalty{Reason: "thin_description", Amount: s.cfg.PenaltyThinDescription})
112		}
113		return penalties
114	}
115
116	func (s *Store) apply(ctx context.Context, user string, penalties []Penalty) (Assessment, error) {
117		// Holding the write lock across the persist serializes concurrent
118		// apply calls. Without it, two in-flight persists can each snapshot
119		// independently and the slower os.Rename can land last, silently
120		// overwriting the newer on-disk state with a stale view.
121		s.mu.Lock()
122		defer s.mu.Unlock()
123
124		prev, ok := s.rep[user]
125		if !ok {
126			prev = s.cfg.StartingScore
127		}
128		score := prev
129		for _, p := range penalties {
130			score -= p.Amount
131		}
132		if score < s.cfg.FloorScore {
133			score = s.cfg.FloorScore
134		}
135		if score > s.cfg.CeilingScore {
136			score = s.cfg.CeilingScore
137		}
138		s.rep[user] = score
139
140		assessment := Assessment{Score: score, Previous: prev, Penalties: penalties}
141		if s.cfg.PersistPath == "" || len(penalties) == 0 {
142			return assessment, nil
143		}
144		if err := s.persistLocked(ctx); err != nil {
145			return assessment, fmt.Errorf("persist reputation: %w", err)
146		}
147		return assessment, nil
148	}
149
150	func (s *Store) load() error {
151		data, err := os.ReadFile(s.cfg.PersistPath)
152		if err != nil {
153			if errors.Is(err, fs.ErrNotExist) {
154				return nil
155			}
156			return fmt.Errorf("read reputation state: %w", err)
157		}
158		if len(data) == 0 {
159			return nil
160		}
161		var rep map[string]int
162		if err := json.Unmarshal(data, &rep); err != nil {
163			return fmt.Errorf("parse reputation state: %w", err)
164		}
165		s.rep = rep
166		return nil
167	}
168
169	// persistLocked writes the in-memory reputation map to disk via a
170	// tmp-file + atomic rename. The caller MUST hold s.mu (write lock);
171	// that's how we keep concurrent persists from racing on the rename.
172	func (s *Store) persistLocked(_ context.Context) error {
173		data, err := json.Marshal(s.rep)
174		if err != nil {
175			return err
176		}
177
178		dir := filepath.Dir(s.cfg.PersistPath)
179		if err := os.MkdirAll(dir, 0o755); err != nil {
180			return err
181		}
182		tmp, err := os.CreateTemp(dir, ".reputation.*.tmp")
183		if err != nil {
184			return err
185		}
186		tmpPath := tmp.Name()
187		defer os.Remove(tmpPath)
188
189		if _, err := tmp.Write(data); err != nil {
190			tmp.Close()
191			return err
192		}
193		if err := tmp.Chmod(0o600); err != nil {
194			tmp.Close()
195			return err
196		}
197		if err := tmp.Sync(); err != nil {
198			tmp.Close()
199			return err
200		}
201		if err := tmp.Close(); err != nil {
202			return err
203		}
204		return os.Rename(tmpPath, s.cfg.PersistPath)
205	}
206

// Package reputation tracks per-user reputation scores derived from the
// quality of Teleport access requests. State is persisted to a local JSON
// file so no external database is required.
package reputation

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sync"
)

type Config struct {
	PersistPath            string `toml:"persist_path"`
	StartingScore          int    `toml:"starting_score"`
	FloorScore             int    `toml:"floor_score"`
	CeilingScore           int    `toml:"ceiling_score"`
	PenaltyMissingCUSTX    int    `toml:"penalty_missing_custx"`
	PenaltyThinDescription int    `toml:"penalty_thin_description"`
}

func (c *Config) ApplyDefaults() {
	if c.StartingScore == 0 {
		c.StartingScore = 100
	}
	if c.CeilingScore == 0 {
		c.CeilingScore = 100
	}
	if c.PenaltyMissingCUSTX == 0 {
		c.PenaltyMissingCUSTX = 10
	}
	if c.PenaltyThinDescription == 0 {
		c.PenaltyThinDescription = 5
	}
}

func (c *Config) Validate() error {
	if c.FloorScore > c.CeilingScore {
		return fmt.Errorf("floor_score (%d) must be <= ceiling_score (%d)", c.FloorScore, c.CeilingScore)
	}
	if c.StartingScore < c.FloorScore || c.StartingScore > c.CeilingScore {
		return fmt.Errorf("starting_score (%d) must be within [%d, %d]", c.StartingScore, c.FloorScore, c.CeilingScore)
	}
	return nil
}

type Penalty struct {
	Reason string
	Amount int
}

type Assessment struct {
	Score     int
	Previous  int
	Penalties []Penalty
}

type Store struct {
	cfg Config
	mu  sync.RWMutex
	rep map[string]int
}

func NewStore(cfg Config) (*Store, error) {
	s := &Store{cfg: cfg, rep: make(map[string]int, 64)}
	if cfg.PersistPath != "" {
		if err := s.load(); err != nil {
			return nil, err
		}
	}
	return s, nil
}

// Score returns the current reputation for user, or StartingScore if unseen.
func (s *Store) Score(user string) int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if v, ok := s.rep[user]; ok {
		return v
	}
	return s.cfg.StartingScore
}

// Range invokes fn for every tracked user under a read lock. Do not call
// back into the store from fn or you will deadlock.
func (s *Store) Range(fn func(user string, score int)) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for u, sc := range s.rep {
		fn(u, sc)
	}
}

// Assess translates risk components into configured penalties, docks the
// user's score, clamps to [floor, ceiling], persists to disk, and returns
// the updated assessment. A component map with no bad signals is a no-op.
func (s *Store) Assess(ctx context.Context, user string, components map[string]int) (Assessment, error) {
	return s.apply(ctx, user, s.penaltiesFor(components))
}

func (s *Store) penaltiesFor(components map[string]int) []Penalty {
	penalties := make([]Penalty, 0, 2)
	if _, ok := components["missing_custx_ticket"]; ok && s.cfg.PenaltyMissingCUSTX > 0 {
		penalties = append(penalties, Penalty{Reason: "missing_custx_ticket", Amount: s.cfg.PenaltyMissingCUSTX})
	}
	if _, ok := components["thin_description"]; ok && s.cfg.PenaltyThinDescription > 0 {
		penalties = append(penalties, Penalty{Reason: "thin_description", Amount: s.cfg.PenaltyThinDescription})
	}
	return penalties
}

func (s *Store) apply(ctx context.Context, user string, penalties []Penalty) (Assessment, error) {
	// Holding the write lock across the persist serializes concurrent
	// apply calls. Without it, two in-flight persists can each snapshot
	// independently and the slower os.Rename can land last, silently
	// overwriting the newer on-disk state with a stale view.
	s.mu.Lock()
	defer s.mu.Unlock()

	prev, ok := s.rep[user]
	if !ok {
		prev = s.cfg.StartingScore
	}
	score := prev
	for _, p := range penalties {
		score -= p.Amount
	}
	if score < s.cfg.FloorScore {
		score = s.cfg.FloorScore
	}
	if score > s.cfg.CeilingScore {
		score = s.cfg.CeilingScore
	}
	s.rep[user] = score

	assessment := Assessment{Score: score, Previous: prev, Penalties: penalties}
	if s.cfg.PersistPath == "" || len(penalties) == 0 {
		return assessment, nil
	}
	if err := s.persistLocked(ctx); err != nil {
		return assessment, fmt.Errorf("persist reputation: %w", err)
	}
	return assessment, nil
}

func (s *Store) load() error {
	data, err := os.ReadFile(s.cfg.PersistPath)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil
		}
		return fmt.Errorf("read reputation state: %w", err)
	}
	if len(data) == 0 {
		return nil
	}
	var rep map[string]int
	if err := json.Unmarshal(data, &rep); err != nil {
		return fmt.Errorf("parse reputation state: %w", err)
	}
	s.rep = rep
	return nil
}

// persistLocked writes the in-memory reputation map to disk via a
// tmp-file + atomic rename. The caller MUST hold s.mu (write lock);
// that's how we keep concurrent persists from racing on the rename.
func (s *Store) persistLocked(_ context.Context) error {
	data, err := json.Marshal(s.rep)
	if err != nil {
		return err
	}

	dir := filepath.Dir(s.cfg.PersistPath)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	tmp, err := os.CreateTemp(dir, ".reputation.*.tmp")
	if err != nil {
		return err
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)

	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpPath, s.cfg.PersistPath)
}


1	package risk
2
3	type Config struct {
4		TicketThreshold int    `toml:"ticket_threshold"`
5		CUSTXPattern    string `toml:"custx_pattern"`
6		OffHoursStart   int    `toml:"off_hours_start"`
7		OffHoursEnd     int    `toml:"off_hours_end"`
8		Timezone        string `toml:"timezone"`
9
10		// EscalationAnnotation is the Teleport system annotation key that marks
11		// an access request as a session escalation. A request is treated as an
12		// escalation when this key is present with any non-empty value.
13		EscalationAnnotation string `toml:"escalation_annotation"`
14		// EscalationThreshold is the fallback ticket threshold applied to
15		// escalation-flavored requests when no role-specific override matches.
16		EscalationThreshold int `toml:"escalation_threshold"`
17		// EscalationThresholds overrides EscalationThreshold for specific
18		// requested roles. The strictest (lowest) matching value wins when a
19		// request names multiple roles.
20		EscalationThresholds map[string]int `toml:"escalation_thresholds"`
21	}
22
23	func (c *Config) ApplyDefaults() {
24		if c.TicketThreshold == 0 {
25			c.TicketThreshold = 25
26		}
27		if c.CUSTXPattern == "" {
28			c.CUSTXPattern = `CUSTX-\d+`
29		}
30		if c.Timezone == "" {
31			c.Timezone = "UTC"
32		}
33		if c.EscalationAnnotation == "" {
34			c.EscalationAnnotation = "ruth.escalation"
35		}
36		if c.EscalationThreshold == 0 {
37			c.EscalationThreshold = 15
38		}
39	}
40
41	// EscalationThresholdFor returns the strictest ticket threshold among the
42	// requested roles. A role without an explicit override uses the global
43	// EscalationThreshold. An empty role list also yields the global default.
44	func (c Config) EscalationThresholdFor(roles []string) int {
45		if len(roles) == 0 {
46			return c.EscalationThreshold
47		}
48		chosen := -1
49		for _, role := range roles {
50			t, ok := c.EscalationThresholds[role]
51			if !ok {
52				t = c.EscalationThreshold
53			}
54			if chosen == -1 || t < chosen {
55				chosen = t
56			}
57		}
58		return chosen
59	}
60
61	type Tier struct {
62		Name     string
63		MinScore int
64	}
65
66	var Tiers = []Tier{
67		{Name: "low", MinScore: 0},
68		{Name: "medium", MinScore: 25},
69		{Name: "high", MinScore: 50},
70		{Name: "critical", MinScore: 75},
71	}
72
73	func TierForScore(score int) Tier {
74		chosen := Tiers[0]
75		for _, t := range Tiers {
76			if score >= t.MinScore {
77				chosen = t
78			}
79		}
80		return chosen
81	}
82

package risk

type Config struct {
	TicketThreshold int    `toml:"ticket_threshold"`
	CUSTXPattern    string `toml:"custx_pattern"`
	OffHoursStart   int    `toml:"off_hours_start"`
	OffHoursEnd     int    `toml:"off_hours_end"`
	Timezone        string `toml:"timezone"`

	// EscalationAnnotation is the Teleport system annotation key that marks
	// an access request as a session escalation. A request is treated as an
	// escalation when this key is present with any non-empty value.
	EscalationAnnotation string `toml:"escalation_annotation"`
	// EscalationThreshold is the fallback ticket threshold applied to
	// escalation-flavored requests when no role-specific override matches.
	EscalationThreshold int `toml:"escalation_threshold"`
	// EscalationThresholds overrides EscalationThreshold for specific
	// requested roles. The strictest (lowest) matching value wins when a
	// request names multiple roles.
	EscalationThresholds map[string]int `toml:"escalation_thresholds"`
}

func (c *Config) ApplyDefaults() {
	if c.TicketThreshold == 0 {
		c.TicketThreshold = 25
	}
	if c.CUSTXPattern == "" {
		c.CUSTXPattern = `CUSTX-\d+`
	}
	if c.Timezone == "" {
		c.Timezone = "UTC"
	}
	if c.EscalationAnnotation == "" {
		c.EscalationAnnotation = "ruth.escalation"
	}
	if c.EscalationThreshold == 0 {
		c.EscalationThreshold = 15
	}
}

// EscalationThresholdFor returns the strictest ticket threshold among the
// requested roles. A role without an explicit override uses the global
// EscalationThreshold. An empty role list also yields the global default.
func (c Config) EscalationThresholdFor(roles []string) int {
	if len(roles) == 0 {
		return c.EscalationThreshold
	}
	chosen := -1
	for _, role := range roles {
		t, ok := c.EscalationThresholds[role]
		if !ok {
			t = c.EscalationThreshold
		}
		if chosen == -1 || t < chosen {
			chosen = t
		}
	}
	return chosen
}

type Tier struct {
	Name     string
	MinScore int
}

var Tiers = []Tier{
	{Name: "low", MinScore: 0},
	{Name: "medium", MinScore: 25},
	{Name: "high", MinScore: 50},
	{Name: "critical", MinScore: 75},
}

func TierForScore(score int) Tier {
	chosen := Tiers[0]
	for _, t := range Tiers {
		if score >= t.MinScore {
			chosen = t
		}
	}
	return chosen
}


1	package ruth
2
3	import (
4		"fmt"
5		"os"
6		"reflect"
7		"strconv"
8		"strings"
9		"time"
10	)
11
12	// envPrefix is the prefix for environment-variable config overrides.
13	// A field tagged `toml:"foo"` inside a section tagged `toml:"bar"` is
14	// overridden by the env var RUTH_BAR_FOO. Maps and slices are intentionally
15	// skipped — they don't map cleanly onto a single environment variable.
16	const envPrefix = "RUTH_"
17
18	var durationType = reflect.TypeOf(time.Duration(0))
19
20	// applyEnv overlays values from RUTH_-prefixed environment variables onto
21	// cfg. Call after TOML unmarshal and before applying defaults so that the
22	// precedence is env > TOML > defaults.
23	func applyEnv(cfg *Config) error {
24		return applyEnvValue(reflect.ValueOf(cfg).Elem(), envPrefix)
25	}
26
27	func applyEnvValue(v reflect.Value, prefix string) error {
28		t := v.Type()
29		for i := 0; i < v.NumField(); i++ {
30			field := t.Field(i)
31			if !field.IsExported() {
32				continue
33			}
34			tag := field.Tag.Get("toml")
35			if tag == "" || tag == "-" {
36				continue
37			}
38			tag = strings.SplitN(tag, ",", 2)[0]
39			key := prefix + strings.ToUpper(tag)
40			fv := v.Field(i)
41
42			if fv.Kind() == reflect.Map || fv.Kind() == reflect.Slice {
43				continue
44			}
45
46			if fv.Kind() == reflect.Struct && fv.Type() != durationType {
47				if err := applyEnvValue(fv, key+"_"); err != nil {
48					return err
49				}
50				continue
51			}
52
53			raw, ok := os.LookupEnv(key)
54			if !ok {
55				continue
56			}
57			if err := setFromString(fv, raw); err != nil {
58				return fmt.Errorf("%s: %w", key, err)
59			}
60		}
61		return nil
62	}
63
64	func setFromString(v reflect.Value, raw string) error {
65		if v.Type() == durationType {
66			d, err := time.ParseDuration(raw)
67			if err != nil {
68				return fmt.Errorf("invalid duration %q: %w", raw, err)
69			}
70			v.SetInt(int64(d))
71			return nil
72		}
73		switch v.Kind() {
74		case reflect.String:
75			v.SetString(raw)
76		case reflect.Bool:
77			b, err := strconv.ParseBool(raw)
78			if err != nil {
79				return fmt.Errorf("invalid bool %q: %w", raw, err)
80			}
81			v.SetBool(b)
82		case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
83			n, err := strconv.ParseInt(raw, 10, 64)
84			if err != nil {
85				return fmt.Errorf("invalid integer %q: %w", raw, err)
86			}
87			v.SetInt(n)
88		case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
89			n, err := strconv.ParseUint(raw, 10, 64)
90			if err != nil {
91				return fmt.Errorf("invalid unsigned integer %q: %w", raw, err)
92			}
93			v.SetUint(n)
94		case reflect.Float32, reflect.Float64:
95			f, err := strconv.ParseFloat(raw, 64)
96			if err != nil {
97				return fmt.Errorf("invalid float %q: %w", raw, err)
98			}
99			v.SetFloat(f)
100		default:
101			return fmt.Errorf("unsupported kind %s", v.Kind())
102		}
103		return nil
104	}
105

package ruth

import (
	"fmt"
	"os"
	"reflect"
	"strconv"
	"strings"
	"time"
)

// envPrefix is the prefix for environment-variable config overrides.
// A field tagged `toml:"foo"` inside a section tagged `toml:"bar"` is
// overridden by the env var RUTH_BAR_FOO. Maps and slices are intentionally
// skipped — they don't map cleanly onto a single environment variable.
const envPrefix = "RUTH_"

var durationType = reflect.TypeOf(time.Duration(0))

// applyEnv overlays values from RUTH_-prefixed environment variables onto
// cfg. Call after TOML unmarshal and before applying defaults so that the
// precedence is env > TOML > defaults.
func applyEnv(cfg *Config) error {
	return applyEnvValue(reflect.ValueOf(cfg).Elem(), envPrefix)
}

func applyEnvValue(v reflect.Value, prefix string) error {
	t := v.Type()
	for i := 0; i < v.NumField(); i++ {
		field := t.Field(i)
		if !field.IsExported() {
			continue
		}
		tag := field.Tag.Get("toml")
		if tag == "" || tag == "-" {
			continue
		}
		tag = strings.SplitN(tag, ",", 2)[0]
		key := prefix + strings.ToUpper(tag)
		fv := v.Field(i)

		if fv.Kind() == reflect.Map || fv.Kind() == reflect.Slice {
			continue
		}

		if fv.Kind() == reflect.Struct && fv.Type() != durationType {
			if err := applyEnvValue(fv, key+"_"); err != nil {
				return err
			}
			continue
		}

		raw, ok := os.LookupEnv(key)
		if !ok {
			continue
		}
		if err := setFromString(fv, raw); err != nil {
			return fmt.Errorf("%s: %w", key, err)
		}
	}
	return nil
}

func setFromString(v reflect.Value, raw string) error {
	if v.Type() == durationType {
		d, err := time.ParseDuration(raw)
		if err != nil {
			return fmt.Errorf("invalid duration %q: %w", raw, err)
		}
		v.SetInt(int64(d))
		return nil
	}
	switch v.Kind() {
	case reflect.String:
		v.SetString(raw)
	case reflect.Bool:
		b, err := strconv.ParseBool(raw)
		if err != nil {
			return fmt.Errorf("invalid bool %q: %w", raw, err)
		}
		v.SetBool(b)
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
		n, err := strconv.ParseInt(raw, 10, 64)
		if err != nil {
			return fmt.Errorf("invalid integer %q: %w", raw, err)
		}
		v.SetInt(n)
	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
		n, err := strconv.ParseUint(raw, 10, 64)
		if err != nil {
			return fmt.Errorf("invalid unsigned integer %q: %w", raw, err)
		}
		v.SetUint(n)
	case reflect.Float32, reflect.Float64:
		f, err := strconv.ParseFloat(raw, 64)
		if err != nil {
			return fmt.Errorf("invalid float %q: %w", raw, err)
		}
		v.SetFloat(f)
	default:
		return fmt.Errorf("unsupported kind %s", v.Kind())
	}
	return nil
}


1	package main
2
3	import (
4		"context"
5		"errors"
6		"flag"
7		"log/slog"
8		"os"
9		"os/signal"
10		"syscall"
11		"time"
12
13		"ruth"
14	)
15
16	func main() {
17		configPath := flag.String("config", "ruth.toml", "Path to TOML configuration")
18		flag.Parse()
19
20		cfg, err := ruth.LoadConfig(*configPath)
21		if err != nil {
22			slog.Error("config load failed", "path", *configPath, "err", err)
23			os.Exit(1)
24		}
25
26		setupLogging(cfg.Log.Level)
27
28		ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
29		defer cancel()
30
31		app, err := ruth.NewApp(ctx, cfg)
32		if err != nil {
33			slog.Error("app init failed", "err", err)
34			os.Exit(1)
35		}
36
37		runErr := app.Run(ctx)
38
39		shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second)
40		defer cancelShutdown()
41		if err := app.Close(shutdownCtx); err != nil {
42			slog.Warn("shutdown error", "err", err)
43		}
44
45		if runErr != nil && !errors.Is(runErr, context.Canceled) {
46			slog.Error("app exited with error", "err", runErr)
47			os.Exit(1)
48		}
49	}
50
51	func setupLogging(level string) {
52		var l slog.Level
53		switch level {
54		case "debug":
55			l = slog.LevelDebug
56		case "warn":
57			l = slog.LevelWarn
58		case "error":
59			l = slog.LevelError
60		default:
61			l = slog.LevelInfo
62		}
63		h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: l})
64		slog.SetDefault(slog.New(h))
65	}
66

package main

import (
	"context"
	"errors"
	"flag"
	"log/slog"
	"os"
	"os/signal"
	"syscall"
	"time"

	"ruth"
)

func main() {
	configPath := flag.String("config", "ruth.toml", "Path to TOML configuration")
	flag.Parse()

	cfg, err := ruth.LoadConfig(*configPath)
	if err != nil {
		slog.Error("config load failed", "path", *configPath, "err", err)
		os.Exit(1)
	}

	setupLogging(cfg.Log.Level)

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	app, err := ruth.NewApp(ctx, cfg)
	if err != nil {
		slog.Error("app init failed", "err", err)
		os.Exit(1)
	}

	runErr := app.Run(ctx)

	shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelShutdown()
	if err := app.Close(shutdownCtx); err != nil {
		slog.Warn("shutdown error", "err", err)
	}

	if runErr != nil && !errors.Is(runErr, context.Canceled) {
		slog.Error("app exited with error", "err", runErr)
		os.Exit(1)
	}
}

func setupLogging(level string) {
	var l slog.Level
	switch level {
	case "debug":
		l = slog.LevelDebug
	case "warn":
		l = slog.LevelWarn
	case "error":
		l = slog.LevelError
	default:
		l = slog.LevelInfo
	}
	h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: l})
	slog.SetDefault(slog.New(h))
}


1	package ai
2
3	import (
4		"bytes"
5		"context"
6		"encoding/json"
7		"fmt"
8		"io"
9		"net/http"
10	)
11
12	type AnthropicConfig struct {
13		APIKey string `toml:"api_key"`
14		Model  string `toml:"model"`
15	}
16
17	type Anthropic struct {
18		cfg    AnthropicConfig
19		client *http.Client
20	}
21
22	func NewAnthropic(cfg AnthropicConfig) *Anthropic {
23		if cfg.Model == "" {
24			cfg.Model = "claude-sonnet-4-6"
25		}
26		return &Anthropic{cfg: cfg, client: &http.Client{}}
27	}
28
29	func (a *Anthropic) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
30		body, err := json.Marshal(map[string]any{
31			"model":      a.cfg.Model,
32			"max_tokens": 1024,
33			"system": []map[string]any{
34				{
35					"type":          "text",
36					"text":          SystemPrompt,
37					"cache_control": map[string]string{"type": "ephemeral"},
38				},
39			},
40			"messages": []map[string]any{
41				{"role": "user", "content": UserPrompt(req)},
42			},
43		})
44		if err != nil {
45			return nil, err
46		}
47		httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
48			"https://api.anthropic.com/v1/messages", bytes.NewReader(body))
49		if err != nil {
50			return nil, err
51		}
52		httpReq.Header.Set("Content-Type", "application/json")
53		httpReq.Header.Set("x-api-key", a.cfg.APIKey)
54		httpReq.Header.Set("anthropic-version", "2023-06-01")
55
56		resp, err := a.client.Do(httpReq)
57		if err != nil {
58			return nil, err
59		}
60		defer resp.Body.Close()
61		if resp.StatusCode >= 300 {
62			raw, _ := io.ReadAll(resp.Body)
63			return nil, fmt.Errorf("anthropic %d: %s", resp.StatusCode, raw)
64		}
65		var out struct {
66			Content []struct {
67				Text string `json:"text"`
68				Type string `json:"type"`
69			} `json:"content"`
70		}
71		if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
72			return nil, err
73		}
74		for _, c := range out.Content {
75			if c.Type == "text" {
76				return parseAssessment(c.Text)
77			}
78		}
79		return nil, fmt.Errorf("no text content in anthropic response")
80	}
81

package ai

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

type AnthropicConfig struct {
	APIKey string `toml:"api_key"`
	Model  string `toml:"model"`
}

type Anthropic struct {
	cfg    AnthropicConfig
	client *http.Client
}

func NewAnthropic(cfg AnthropicConfig) *Anthropic {
	if cfg.Model == "" {
		cfg.Model = "claude-sonnet-4-6"
	}
	return &Anthropic{cfg: cfg, client: &http.Client{}}
}

func (a *Anthropic) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
	body, err := json.Marshal(map[string]any{
		"model":      a.cfg.Model,
		"max_tokens": 1024,
		"system": []map[string]any{
			{
				"type":          "text",
				"text":          SystemPrompt,
				"cache_control": map[string]string{"type": "ephemeral"},
			},
		},
		"messages": []map[string]any{
			{"role": "user", "content": UserPrompt(req)},
		},
	})
	if err != nil {
		return nil, err
	}
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"https://api.anthropic.com/v1/messages", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("x-api-key", a.cfg.APIKey)
	httpReq.Header.Set("anthropic-version", "2023-06-01")

	resp, err := a.client.Do(httpReq)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("anthropic %d: %s", resp.StatusCode, raw)
	}
	var out struct {
		Content []struct {
			Text string `json:"text"`
			Type string `json:"type"`
		} `json:"content"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	for _, c := range out.Content {
		if c.Type == "text" {
			return parseAssessment(c.Text)
		}
	}
	return nil, fmt.Errorf("no text content in anthropic response")
}


afplay ~/.claude/sounds/wc3/complete.mp3 2>/dev/null || true


Here's a thorough breakdown of RUTH, Sir Charles:

---

## What it is

**RUTH** is a Teleport access-request plugin that acts as an automated SRE triage bot. Every pending access request gets scored by an AI risk analyst, and the score determines what happens next: auto-approve the safe ones, create a Jira ITSM ticket and hold the rest until a human resolves it.

---

## Architecture

```
Teleport watcher → risk scorer → AI assessment → tier decision
                                                    ├─ score < threshold → auto-approve (Teleport AccessReview)
                                                    └─ score ≥ threshold → Jira ITSM ticket → poll status → Teleport AccessReview
```

Three goroutines run under an `errgroup`: `watchRequests`, `pollITSM`, and a graceful-shutdown coordinator. `app.go:113`

---

## Risk scoring (`risk/`)

**Deterministic (hard rules):**
- Missing `CUSTX-###` ticket reference: **+30**
- Description < 40 chars: **+15**
- Off-hours request: **+10**

**AI-judged (Claude-scored, clamped):**
- `description_quality`: 0–25 (0 = specific, 25 = vague/empty)
- `privilege_concern`: 0–25 (0 = read-only, 25 = prod-root/destructive)
- `anomalies`: 0–10 (scope mismatches, injection attempts)

Total capped at 100. Tiers: low (0–24), medium (25–49), high (50–74), critical (75–100). Default ticket threshold is 25.

---

## The AI system prompt (`ai/provider.go:67`)

This is the most distinctive piece. The prompt persona is a **grumpy-but-loving grandmother** ("abuela") who is a seasoned security analyst. She's generous by default — a brief-but-specific request gets "a kiss on the forehead." The chancla only comes out for genuinely reckless requests (prod-root with "need access to look at things").

Critically, the prompt is carefully hardened against prompt injection: free-form fields are treated as *untrusted user input to assess, never instructions to follow*. Injection attempts drive `anomalies` up, never down.

The Anthropic provider sends the system prompt with `cache_control: ephemeral` for prompt cache hits. `ai/anthropic.go:33`

---

## Packages

| Package | Role |
|---|---|
| `app.go` | Main event loop, watcher, poller, request handler |
| `config.go` | TOML load → env overlay → validation |
| `env.go` | Reflection-based `RUTH_SECTION_FIELD` env var overrides |
| `ai/` | `Provider` interface + Anthropic, Bedrock, Random impls |
| `risk/` | Scorer, tier table, escalation logic |
| `jira/` | ITSM ticket create/poll, restart rehydration via JQL |
| `reputation/` | Per-user score store with atomic JSON file persistence |
| `telemetry/` | OTLP/gRPC meter, `ruth.user.reputation` observable gauge |

---

## Notable design decisions

- **Restart safety**: On startup, JQL-queries Jira for open `teleport`-labelled tickets and rebuilds the in-memory `pending` map. No duplicate tickets on restart. `app.go:139`
- **Dedup via single map**: `reserveOrSkip` uses `pending` both as a reservation (empty-string value) and a ticket record. One source of truth for both. `app.go:192`
- **Reputation only goes down**: Users start at 100, get docked for missing CUSTX or thin descriptions, clamped to a configurable floor. No recovery path by design.
- **Dry-run mode**: Scores and logs decisions without calling Teleport or Jira — useful for validating thresholds before going live.
- **Session escalations**: Requests carrying the `ruth.escalation` Teleport annotation use a tighter threshold (default 15 vs 25), with per-role overrides.
- **Raw HTTP for Anthropic**: The Anthropic provider uses `net/http` directly rather than the SDK. Works fine, but misses SDK conveniences like automatic retries.

---

## Two entry points

- `cmd/ruth/main.go` — the daemon
- `cmd/submit/main.go` — presumably a manual request submission tool (worth checking if it's in-progress)


<command-message>obsidian-save</command-message>
<command-name>/obsidian-save</command-name>


Save current conversation context to an Obsidian note under the `Claude/` directory.

## What to save

Capture all of the following that are relevant to the current conversation:

- **Conversation summary**: What the user is trying to accomplish and why
- **Project context**: Relevant architecture, design decisions, or constraints discovered
- **Task state**: What has been completed, what is in progress, what is blocked or pending
- **Key decisions**: Any choices made and the reasoning behind them
- **Open questions**: Unresolved questions or things to revisit
- **Next steps**: Concrete actions that should follow from this session

## File location

Derive the project name from the current working directory (use the repo or app folder name). Save to:

```
Claude/<project-name>/<YYYY-MM-DD>-<short-slug>.md
```

Use today's date and a short 2-4 word slug describing the session topic (e.g. `2026-04-29-ruth-plugin-setup.md`).

If `` is provided, use it as the slug instead of generating one.

## Note format

Use this structure:

```markdown
---
date: <YYYY-MM-DD>
project: <project-name>
tags: [claude-context]
---

# <Session Title>

## Summary
<1-3 sentence overview of what this session was about>

## Context
<Relevant project background, architecture notes, constraints>

## Decisions
<Decisions made and rationale>

## Task State
<What was completed, in progress, or blocked>

## Open Questions
<Anything unresolved>

## Next Steps
<Concrete follow-up actions>
```

## Steps

1. Determine the project name from the working directory.
2. Determine today's date.
3. Draft the note content based on the current conversation.
4. Use `mcp__obsidian__create_vault_file` to write the note to the vault.
5. Confirm to the user where the note was saved.


---
date: 2026-04-29
project: ruth
tags: [claude-context]
---

# RUTH Plugin — Codebase Analysis

## Summary
Explored and analyzed the `ruth` Teleport access-request plugin from scratch. The session covered the full architecture, risk scoring model, AI integration, reputation system, and notable design decisions.

## Context

### What RUTH Is
RUTH is a Go daemon that watches Teleport for pending access requests, scores each one via a combination of deterministic rules and an AI provider (Anthropic, AWS Bedrock, or a test-only random provider), then either auto-approves low-risk requests or creates a Jira ITSM ticket and holds the request PENDING until an SRE resolves it.

### Repository Location
`apps/ruth/` inside the operations monorepo. Currently on branch `ruth-plugin`.

### Architecture
```
Teleport watcher → risk scorer → AI assessment → tier decision
                                                    ├─ score < threshold → auto-approve
                                                    └─ score ≥ threshold → Jira ITSM ticket → poll → Teleport AccessReview
```

Three goroutines under an `errgroup`: `watchRequests`, `pollITSM`, graceful-shutdown coordinator (`app.go:113`).

### Risk Scoring (`risk/`)
**Deterministic:**
- Missing `CUSTX-###` ticket: +30
- Description < 40 chars: +15
- Off-hours request: +10

**AI-judged (clamped):**
- `description_quality`: 0–25
- `privilege_concern`: 0–25
- `anomalies`: 0–10

Total capped at 100. Tiers: low (0–24), medium (25–49), high (50–74), critical (75–100). Default ticket threshold: 25.

### AI System Prompt (`ai/provider.go:67`)
The persona is a **grumpy-but-loving grandmother** ("abuela") who is a seasoned security analyst. Generous by default; the chancla only comes out for genuinely reckless requests. Hardened against prompt injection — free-form fields are treated as untrusted input to assess, never instructions to follow. Injection attempts raise `anomalies`, never lower scores.

### Package Map
| Package | Role |
|---|---|
| `app.go` | Main event loop, watcher, poller, request handler |
| `config.go` | TOML load → env overlay → validation |
| `env.go` | Reflection-based `RUTH_SECTION_FIELD` env var overrides |
| `ai/` | `Provider` interface + Anthropic, Bedrock, Random impls |
| `risk/` | Scorer, tier table, escalation logic |
| `jira/` | ITSM ticket create/poll, restart rehydration via JQL |
| `reputation/` | Per-user score store, atomic JSON file persistence |
| `telemetry/` | OTLP/gRPC meter, `ruth.user.reputation` observable gauge |

### Key Design Decisions
- **Restart safety**: On startup, JQL-queries Jira for open `teleport`-labelled tickets to rebuild `pending` map. No duplicate tickets on restart.
- **Dedup via single map**: `reserveOrSkip` uses `pending` for both in-flight reservations (empty string value) and confirmed ticket records (`app.go:192`).
- **Reputation only goes down**: Users start at 100, docked for missing CUSTX or thin descriptions, clamped to configurable floor. No recovery path by design.
- **Dry-run mode**: Scores and logs decisions without calling Teleport or Jira.
- **Session escalations**: Requests with `ruth.escalation` annotation use a tighter threshold (default 15 vs 25), with per-role overrides.
- **Raw HTTP for Anthropic**: `ai/anthropic.go` uses `net/http` directly, not the Anthropic SDK. System prompt sent with `cache_control: ephemeral` for prompt cache hits.
- **Env var overrides**: Reflection-based `RUTH_SECTION_FIELD` pattern covers all scalar TOML keys. Maps/slices are TOML-only.

### Entry Points
- `cmd/ruth/main.go` — the daemon
- `cmd/submit/main.go` — manual request submission tool (status unclear)

## Decisions
No implementation changes made this session — read-only analysis.

## Task State
- [x] Full codebase analysis complete

## Open Questions
- What is `cmd/submit/main.go` used for? It wasn't read this session.
- The Anthropic provider uses raw HTTP — is there a reason the Anthropic SDK wasn't used? Possible migration target.
- Are there deployment manifests / Kubernetes configs for ruth elsewhere in the repo?

## Next Steps
- Read `cmd/submit/main.go` to understand its purpose
- Check if there are Kubernetes/Helm configs for deploying ruth
- Consider migrating `ai/anthropic.go` to the Anthropic SDK for retry handling and cleaner beta header management


File created successfully

File created successfully


Saved to `Claude/ruth/2026-04-29-ruth-plugin-analysis.md` in your vault.


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/model</command-name>
            <command-message>model</command-message>
            <command-args></command-args>


<local-command-stdout>Set model to Opus 4.7 (1M context) with xhigh effort</local-command-stdout>


can we refactor this code to remove the anthropic AI provider completely?


Yes, Sir Charles of St Cheese, easily done — `ai/anthropic.go` deletes cleanly, the `anthropic` cases come out of `ai/provider.go`'s `New()` and `Validate()` switches, the `AnthropicConfig` struct goes, and the README + `ruth.toml.example` lose a section each. The `SystemPrompt` constant stays put since Bedrock still uses it.

Worth a sanity check before I start swinging the axe: you want **Bedrock + random** as the surviving providers, yes? That leaves Bedrock as the production path (IRSA / Pod Identity, no API key sprawl) and `random` for local dev/tests. If that's the shape you want, say the word and I'll execute.


---
date: 2026-04-29
project: ruth
tags: [claude-context]
---

# RUTH — Remove Anthropic AI Provider

## Summary
Sir Charles asked whether the Anthropic AI provider can be removed from the `ruth` plugin. Scoped the refactor and proposed Bedrock + random as the surviving providers; awaiting confirmation before implementing.

## Context

### Project
`apps/ruth/` — Teleport access-request plugin in Go that scores requests via an AI provider, auto-approves low risk ones, and files Jira ITSM tickets for the rest. Branch: `ruth-plugin`.

### Current AI Provider Setup (`ai/`)
Three providers behind a small `Provider` interface:
- **`anthropic`** — direct HTTP calls to Anthropic Messages API (`ai/anthropic.go`). System prompt sent with `cache_control: ephemeral`.
- **`bedrock`** — `bedrockruntime:InvokeModel` against Claude-on-Bedrock; uses default AWS credential chain (IRSA / Pod Identity / env / profile). Production path.
- **`random`** — test-only fabricated assessments for local dev / integration tests.

`SystemPrompt` (the abuela-grandmother persona) lives in `ai/provider.go` and is shared by both Anthropic and Bedrock — it stays put after Anthropic removal because Bedrock still needs it.

### Removal Surface
The deletion touches:
1. `ai/anthropic.go` — delete entire file.
2. `ai/provider.go` — remove `anthropic` cases from `Validate()` and `New()`; remove `Anthropic AnthropicConfig` field from `Config`; remove the `AnthropicConfig` struct (currently in `ai/anthropic.go`).
3. `README.md` — drop the `anthropic` bullet under "AI providers", drop the `RUTH_AI_ANTHROPIC_*` env var rows from the env-override table.
4. `ruth.toml.example` — drop the `[ai.anthropic]` section (not yet read in this session, but implied by config layout).
5. Possible test files referencing anthropic (not yet checked).

### Why This Is Probably Worth Doing
- Bedrock is the production path: IRSA / Pod Identity means no API key management.
- Anthropic provider was likely for direct local dev — but `random` already covers that case.
- Fewer provider paths = simpler config validation, smaller blast radius for security review.

## Decisions
- **Recommendation made**: Keep Bedrock (prod) + random (dev/test). Drop Anthropic.
- **System prompt stays**: Shared by Bedrock, no reason to touch it.

## Task State
- [x] Scoped the refactor (files identified)
- [ ] User confirmation on "Bedrock + random as survivors" — **awaiting**
- [ ] Execute deletion across 4–5 files
- [ ] Verify no lingering references via grep
- [ ] Run tests with `-race`

## Open Questions
- Confirm with Sir Charles that Bedrock + random is the desired surviving set.
- Are there tests that exercise the Anthropic provider directly? Check before deletion.
- Does `ruth.toml.example` have an `[ai.anthropic]` section that needs removing?
- Is there a deployment manifest or Helm chart elsewhere that injects `RUTH_AI_ANTHROPIC_*` env vars?

## Next Steps
1. Wait for user confirmation on provider survivors.
2. Once confirmed:
   - Delete `ai/anthropic.go`.
   - Edit `ai/provider.go` to remove `anthropic` cases + struct field.
   - Update `README.md` (provider list + env-var table).
   - Update `ruth.toml.example` to drop the `[ai.anthropic]` block.
   - Grep for any remaining `Anthropic` / `anthropic` references in tests or configs.
   - Run `go build ./...` and `go test -race ./...`.


Saved to `Claude/ruth/2026-04-29-ruth-remove-anthropic-provider.md`.


yes that is correct, I want only bedrock and the random as the surviving providers


Right. Let me first sweep for every Anthropic reference so I don't leave bits dangling.


Bash

grep -rni "anthropic" --include="*.go" --include="*.toml" --include="*.example" --include="*.md" --include="*.yaml" --include="*.yml" -l

Find all files referencing anthropic


ruth.toml.example
config_test.go
env_test.go
README.md
local/README.md
ai/anthropic.go
ai/provider.go
ai/bedrock.go


1	# RUTH — Teleport access plugin with AI risk scoring
2	# Copy to /etc/ruth/ruth.toml and fill in.
3	#
4	# Any scalar key can also be overridden via a RUTH_-prefixed environment
5	# variable (e.g. RUTH_TELEPORT_ADDR, RUTH_AI_ANTHROPIC_API_KEY,
6	# RUTH_JIRA_POLL_INTERVAL=2m). Precedence: env > this file > defaults.
7	# Maps (risk.escalation_thresholds) and slices are TOML-only.
8
9	# When true, log the decision (auto-approve / would-create-ticket) for each
10	# access request without calling Teleport or Jira. Useful for previewing a
11	# new threshold or AI provider before flipping the switch in prod.
12	# dry_run = false
13
14	[teleport]
15	addr = "proxy.example.com:443"
16	identity_file = "/var/lib/teleport/ruth.pem"
17
18	[ai]
19	# Exactly one of: "anthropic", "bedrock", "random".
20	# "random" is for local dev / integration tests only — it fabricates
21	# assessments and must not be used in production.
22	provider = "anthropic"
23
24	[ai.anthropic]
25	api_key = "sk-ant-..."
26	model = "claude-sonnet-4-6"
27
28	[ai.bedrock]
29	region = "us-west-2"
30	model_id = "us.anthropic.claude-sonnet-4-6-v1:0"
31	# Authentication: by default the AWS SDK default credential chain is used,
32	# which covers IRSA, EKS Pod Identity, EC2 instance profiles, ECS task roles,
33	# env vars, and ~/.aws/credentials — no config needed in prod.
34	# profile = "dev"                           # named shared-config profile (local dev)
35	# role_arn = "arn:aws:iam::222222222222:role/bedrock-invoker"  # cross-account AssumeRole
36	# role_session_name = "ruth"
37	# external_id = "optional-external-id"
38
39	[ai.random]
40	# Optional: set a non-zero seed for deterministic output (useful in CI).
41	# seed = 0
42
43	[jira]
44	url = "https://example.atlassian.net"
45	username = "bot@example.com"
46	api_token = "replace-me"
47	project = "ITSM"
48	issue_type = "Task"
49	poll_interval = "30s"
50	approved_status = "Approved"
51	rejected_status = "Rejected"
52
53	[risk]
54	# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
55	# Anything below is auto-approved as "no cap".
56	ticket_threshold = 25
57	custx_pattern = 'CUSTX-\d+'
58	# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
59	off_hours_start = 18
60	off_hours_end = 8
61	timezone = "America/New_York"
62
63	# Session escalations — access requests that Teleport tags with the
64	# escalation_annotation (set via the role's allow.request.annotations).
65	# These use a tighter fallback threshold. Per-role overrides in
66	# [risk.escalation_thresholds] take precedence; the strictest (lowest)
67	# value wins when a request names multiple roles.
68	escalation_annotation = "ruth.escalation"
69	escalation_threshold  = 15
70
71	[risk.escalation_thresholds]
72	# "sre-admin"    = 5
73	# "db-admin"     = 10
74	# "read-only"    = 25
75
76	[reputation]
77	# Per-user reputation, persisted as JSON on local disk. Every user starts
78	# at starting_score; points are deducted for bad requests and clamped to
79	# [floor_score, ceiling_score]. Reputation only goes down — the point is
80	# to shame, not to forgive.
81	persist_path              = "/var/lib/ruth/reputation.json"
82	starting_score            = 100
83	floor_score               = 0
84	ceiling_score             = 100
85	penalty_missing_custx     = 10
86	penalty_thin_description  = 5
87
88	[telemetry]
89	# Emit per-user reputation as an OTLP gauge for the collector to ship to
90	# Datadog. Disable to keep reputation state local only.
91	enabled        = true
92	endpoint       = "otel-collector:4317"
93	insecure       = true
94	service_name   = "ruth"
95	export_period  = "15s"
96	# Delay between receiving SIGINT/SIGTERM and cancelling the watcher so the
97	# OTel periodic reader can finish an in-flight export cleanly. Set to "0s"
98	# to shut down immediately at the cost of a noisy "context canceled"
99	# exporter error.
100	shutdown_grace = "2s"
101
102	[log]
103	level = "info"
104

# RUTH — Teleport access plugin with AI risk scoring
# Copy to /etc/ruth/ruth.toml and fill in.
#
# Any scalar key can also be overridden via a RUTH_-prefixed environment
# variable (e.g. RUTH_TELEPORT_ADDR, RUTH_AI_ANTHROPIC_API_KEY,
# RUTH_JIRA_POLL_INTERVAL=2m). Precedence: env > this file > defaults.
# Maps (risk.escalation_thresholds) and slices are TOML-only.

# When true, log the decision (auto-approve / would-create-ticket) for each
# access request without calling Teleport or Jira. Useful for previewing a
# new threshold or AI provider before flipping the switch in prod.
# dry_run = false

[teleport]
addr = "proxy.example.com:443"
identity_file = "/var/lib/teleport/ruth.pem"

[ai]
# Exactly one of: "anthropic", "bedrock", "random".
# "random" is for local dev / integration tests only — it fabricates
# assessments and must not be used in production.
provider = "anthropic"

[ai.anthropic]
api_key = "sk-ant-..."
model = "claude-sonnet-4-6"

[ai.bedrock]
region = "us-west-2"
model_id = "us.anthropic.claude-sonnet-4-6-v1:0"
# Authentication: by default the AWS SDK default credential chain is used,
# which covers IRSA, EKS Pod Identity, EC2 instance profiles, ECS task roles,
# env vars, and ~/.aws/credentials — no config needed in prod.
# profile = "dev"                           # named shared-config profile (local dev)
# role_arn = "arn:aws:iam::222222222222:role/bedrock-invoker"  # cross-account AssumeRole
# role_session_name = "ruth"
# external_id = "optional-external-id"

[ai.random]
# Optional: set a non-zero seed for deterministic output (useful in CI).
# seed = 0

[jira]
url = "https://example.atlassian.net"
username = "bot@example.com"
api_token = "replace-me"
project = "ITSM"
issue_type = "Task"
poll_interval = "30s"
approved_status = "Approved"
rejected_status = "Rejected"

[risk]
# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
# Anything below is auto-approved as "no cap".
ticket_threshold = 25
custx_pattern = 'CUSTX-\d+'
# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
off_hours_start = 18
off_hours_end = 8
timezone = "America/New_York"

# Session escalations — access requests that Teleport tags with the
# escalation_annotation (set via the role's allow.request.annotations).
# These use a tighter fallback threshold. Per-role overrides in
# [risk.escalation_thresholds] take precedence; the strictest (lowest)
# value wins when a request names multiple roles.
escalation_annotation = "ruth.escalation"
escalation_threshold  = 15

[risk.escalation_thresholds]
# "sre-admin"    = 5
# "db-admin"     = 10
# "read-only"    = 25

[reputation]
# Per-user reputation, persisted as JSON on local disk. Every user starts
# at starting_score; points are deducted for bad requests and clamped to
# [floor_score, ceiling_score]. Reputation only goes down — the point is
# to shame, not to forgive.
persist_path              = "/var/lib/ruth/reputation.json"
starting_score            = 100
floor_score               = 0
ceiling_score             = 100
penalty_missing_custx     = 10
penalty_thin_description  = 5

[telemetry]
# Emit per-user reputation as an OTLP gauge for the collector to ship to
# Datadog. Disable to keep reputation state local only.
enabled        = true
endpoint       = "otel-collector:4317"
insecure       = true
service_name   = "ruth"
export_period  = "15s"
# Delay between receiving SIGINT/SIGTERM and cancelling the watcher so the
# OTel periodic reader can finish an in-flight export cleanly. Set to "0s"
# to shut down immediately at the cost of a noisy "context canceled"
# exporter error.
shutdown_grace = "2s"

[log]
level = "info"


1	# RUTH — Teleport access plugin with AI risk scoring
2	# Copy to /etc/ruth/ruth.toml and fill in.
3
4	[teleport]
5	addr = "proxy.example.com:443"
6	identity_file = "/var/lib/teleport/ruth.pem"
7
8	[ai]
9	# Exactly one of: "anthropic", "bedrock", "webhook"
10	provider = "anthropic"
11
12	[ai.anthropic]
13	api_key = "sk-ant-..."
14	model = "claude-sonnet-4-6"
15
16	[ai.bedrock]
17	region = "us-west-2"
18	model_id = "us.anthropic.claude-sonnet-4-6-v1:0"
19
20	[ai.webhook]
21	url = "https://chat.internal/score"
22	auth_header = "Bearer replace-me"
23	# [ai.webhook.headers]
24	# X-Tenant = "ruth"
25
26	[jira]
27	url = "https://example.atlassian.net"
28	username = "bot@example.com"
29	api_token = "replace-me"
30	project = "ITSM"
31	issue_type = "Task"
32	poll_interval = 30
33	approved_status = "Approved"
34	rejected_status = "Rejected"
35
36	[risk]
37	# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
38	# Anything below is auto-approved as "no cap".
39	ticket_threshold = 25
40	custx_pattern = 'CUSTX-\d+'
41	# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
42	off_hours_start = 18
43	off_hours_end = 8
44	timezone = "America/New_York"
45
46	[reputation]
47	# Per-user reputation, persisted as JSON on local disk. Every user starts
48	# at starting_score; points are deducted for bad requests and clamped to
49	# [floor_score, ceiling_score]. Reputation only goes down — the point is
50	# to shame, not to forgive.
51	persist_path              = "/var/lib/ruth/reputation.json"
52	starting_score            = 100
53	floor_score               = 0
54	ceiling_score             = 100
55	penalty_missing_custx     = 10
56	penalty_thin_description  = 5
57
58	[telemetry]
59	# Emit per-user reputation as an OTLP gauge for the collector to ship to
60	# Datadog. Disable to keep reputation state local only.
61	enabled       = true
62	endpoint      = "otel-collector:4317"
63	insecure      = true
64	service_name  = "ruth"
65	export_period = 15
66
67	[log]
68	level = "info"
69

# RUTH — Teleport access plugin with AI risk scoring
# Copy to /etc/ruth/ruth.toml and fill in.

[teleport]
addr = "proxy.example.com:443"
identity_file = "/var/lib/teleport/ruth.pem"

[ai]
# Exactly one of: "anthropic", "bedrock", "webhook"
provider = "anthropic"

[ai.anthropic]
api_key = "sk-ant-..."
model = "claude-sonnet-4-6"

[ai.bedrock]
region = "us-west-2"
model_id = "us.anthropic.claude-sonnet-4-6-v1:0"

[ai.webhook]
url = "https://chat.internal/score"
auth_header = "Bearer replace-me"
# [ai.webhook.headers]
# X-Tenant = "ruth"

[jira]
url = "https://example.atlassian.net"
username = "bot@example.com"
api_token = "replace-me"
project = "ITSM"
issue_type = "Task"
poll_interval = 30
approved_status = "Approved"
rejected_status = "Rejected"

[risk]
# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
# Anything below is auto-approved as "no cap".
ticket_threshold = 25
custx_pattern = 'CUSTX-\d+'
# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
off_hours_start = 18
off_hours_end = 8
timezone = "America/New_York"

[reputation]
# Per-user reputation, persisted as JSON on local disk. Every user starts
# at starting_score; points are deducted for bad requests and clamped to
# [floor_score, ceiling_score]. Reputation only goes down — the point is
# to shame, not to forgive.
persist_path              = "/var/lib/ruth/reputation.json"
starting_score            = 100
floor_score               = 0
ceiling_score             = 100
penalty_missing_custx     = 10
penalty_thin_description  = 5

[telemetry]
# Emit per-user reputation as an OTLP gauge for the collector to ship to
# Datadog. Disable to keep reputation state local only.
enabled       = true
endpoint      = "otel-collector:4317"
insecure      = true
service_name  = "ruth"
export_period = 15

[log]
level = "info"


Let me set up tracking and check the remaining files referencing anthropic.


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


TaskCreate

Delete ai/anthropic.go

Remove the Anthropic provider source file entirely.


Task #1 created successfully: Delete ai/anthropic.go

Delete ai/anthropic.go


Strip anthropic from ai/provider.go

Remove the anthropic case from Validate() and New(); remove Anthropic field from Config struct.


Task #2 created successfully: Strip anthropic from ai/provider.go

Strip anthropic from ai/provider.go


Update test files

Remove anthropic references from config_test.go and env_test.go.


Task #3 created successfully: Update test files

Update test files


Update ruth.toml.example and ruth.toml

Drop [ai.anthropic] sections; switch default provider; update env-var comment.


Task #4 created successfully: Update ruth.toml.example and ruth.toml

Update ruth.toml.example and ruth.toml


Update README.md

Drop anthropic bullet from "AI providers" section and remove RUTH_AI_ANTHROPIC_* env-var rows from the override table.


Task #5 created successfully: Update README.md

Update README.md


Update local/README.md

Drop any anthropic references in the local dev README.


Task #6 created successfully: Update local/README.md

Update local/README.md


Verify build and tests pass

Run go build ./... and go test -race ./... to confirm nothing references the removed code.


Task #7 created successfully: Verify build and tests pass

Verify build and tests pass


1	package ruth
2
3	import (
4		"os"
5		"path/filepath"
6		"testing"
7	)
8
9	func writeConfig(t *testing.T, body string) string {
10		t.Helper()
11		dir := t.TempDir()
12		path := filepath.Join(dir, "ruth.toml")
13		if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
14			t.Fatalf("write config: %v", err)
15		}
16		return path
17	}
18
19	// Explicit zeros in TOML must not be silently resurrected by ApplyDefaults.
20	// This is the integration-level expression of the bug where a user who
21	// wants to disable a penalty by setting it to 0 would otherwise get the
22	// default value back.
23	func TestLoadConfig_ExplicitZeroDisablesDefaultBackedField(t *testing.T) {
24		path := writeConfig(t, `
25	[teleport]
26	addr          = "proxy.example.com:443"
27	identity_file = "/tmp/ruth.pem"
28
29	[ai]
30	provider = "anthropic"
31
32	[ai.anthropic]
33	api_key = "sk-test"
34
35	[jira]
36	url       = "https://example.atlassian.net"
37	username  = "bot@example.com"
38	api_token = "token"
39	project   = "ITSM"
40
41	[risk]
42	ticket_threshold     = 0
43	escalation_threshold = 0
44
45	[reputation]
46	penalty_missing_custx    = 0
47	penalty_thin_description = 0
48	`)
49
50		cfg, err := LoadConfig(path)
51		if err != nil {
52			t.Fatalf("LoadConfig: %v", err)
53		}
54
55		if cfg.Reputation.PenaltyMissingCUSTX != 0 {
56			t.Errorf("PenaltyMissingCUSTX = %d, want 0 (TOML disable must win over default)", cfg.Reputation.PenaltyMissingCUSTX)
57		}
58		if cfg.Reputation.PenaltyThinDescription != 0 {
59			t.Errorf("PenaltyThinDescription = %d, want 0", cfg.Reputation.PenaltyThinDescription)
60		}
61		if cfg.Risk.TicketThreshold != 0 {
62			t.Errorf("Risk.TicketThreshold = %d, want 0", cfg.Risk.TicketThreshold)
63		}
64		if cfg.Risk.EscalationThreshold != 0 {
65			t.Errorf("Risk.EscalationThreshold = %d, want 0", cfg.Risk.EscalationThreshold)
66		}
67	}
68
69	// Absent keys should still receive defaults — flipping the layering order
70	// must not regress the common case of "I didn't set this; give me sane defaults".
71	func TestLoadConfig_AbsentKeysGetDefaults(t *testing.T) {
72		path := writeConfig(t, `
73	[teleport]
74	addr          = "proxy.example.com:443"
75	identity_file = "/tmp/ruth.pem"
76
77	[ai]
78	provider = "anthropic"
79
80	[ai.anthropic]
81	api_key = "sk-test"
82
83	[jira]
84	url       = "https://example.atlassian.net"
85	username  = "bot@example.com"
86	api_token = "token"
87	project   = "ITSM"
88	`)
89
90		cfg, err := LoadConfig(path)
91		if err != nil {
92			t.Fatalf("LoadConfig: %v", err)
93		}
94
95		if got, want := cfg.Risk.TicketThreshold, 25; got != want {
96			t.Errorf("Risk.TicketThreshold = %d, want %d (default)", got, want)
97		}
98		if got, want := cfg.Risk.EscalationThreshold, 15; got != want {
99			t.Errorf("Risk.EscalationThreshold = %d, want %d (default)", got, want)
100		}
101		if got, want := cfg.Reputation.PenaltyMissingCUSTX, 10; got != want {
102			t.Errorf("PenaltyMissingCUSTX = %d, want %d (default)", got, want)
103		}
104		if got, want := cfg.Reputation.StartingScore, 100; got != want {
105			t.Errorf("StartingScore = %d, want %d (default)", got, want)
106		}
107		if got, want := cfg.Log.Level, "info"; got != want {
108			t.Errorf("Log.Level = %q, want %q (default)", got, want)
109		}
110	}
111
112	// Env var overrides still take precedence over both TOML and defaults,
113	// including when the TOML value is an explicit zero.
114	func TestLoadConfig_EnvBeatsTOMLZero(t *testing.T) {
115		t.Setenv("RUTH_RISK_TICKET_THRESHOLD", "42")
116		path := writeConfig(t, `
117	[teleport]
118	addr          = "proxy.example.com:443"
119	identity_file = "/tmp/ruth.pem"
120
121	[ai]
122	provider = "anthropic"
123
124	[ai.anthropic]
125	api_key = "sk-test"
126
127	[jira]
128	url       = "https://example.atlassian.net"
129	username  = "bot@example.com"
130	api_token = "token"
131	project   = "ITSM"
132
133	[risk]
134	ticket_threshold = 0
135	`)
136
137		cfg, err := LoadConfig(path)
138		if err != nil {
139			t.Fatalf("LoadConfig: %v", err)
140		}
141		if cfg.Risk.TicketThreshold != 42 {
142			t.Errorf("Risk.TicketThreshold = %d, want 42 (env > TOML > default)", cfg.Risk.TicketThreshold)
143		}
144	}
145

package ruth

import (
	"os"
	"path/filepath"
	"testing"
)

func writeConfig(t *testing.T, body string) string {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "ruth.toml")
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		t.Fatalf("write config: %v", err)
	}
	return path
}

// Explicit zeros in TOML must not be silently resurrected by ApplyDefaults.
// This is the integration-level expression of the bug where a user who
// wants to disable a penalty by setting it to 0 would otherwise get the
// default value back.
func TestLoadConfig_ExplicitZeroDisablesDefaultBackedField(t *testing.T) {
	path := writeConfig(t, `
[teleport]
addr          = "proxy.example.com:443"
identity_file = "/tmp/ruth.pem"

[ai]
provider = "anthropic"

[ai.anthropic]
api_key = "sk-test"

[jira]
url       = "https://example.atlassian.net"
username  = "bot@example.com"
api_token = "token"
project   = "ITSM"

[risk]
ticket_threshold     = 0
escalation_threshold = 0

[reputation]
penalty_missing_custx    = 0
penalty_thin_description = 0
`)

	cfg, err := LoadConfig(path)
	if err != nil {
		t.Fatalf("LoadConfig: %v", err)
	}

	if cfg.Reputation.PenaltyMissingCUSTX != 0 {
		t.Errorf("PenaltyMissingCUSTX = %d, want 0 (TOML disable must win over default)", cfg.Reputation.PenaltyMissingCUSTX)
	}
	if cfg.Reputation.PenaltyThinDescription != 0 {
		t.Errorf("PenaltyThinDescription = %d, want 0", cfg.Reputation.PenaltyThinDescription)
	}
	if cfg.Risk.TicketThreshold != 0 {
		t.Errorf("Risk.TicketThreshold = %d, want 0", cfg.Risk.TicketThreshold)
	}
	if cfg.Risk.EscalationThreshold != 0 {
		t.Errorf("Risk.EscalationThreshold = %d, want 0", cfg.Risk.EscalationThreshold)
	}
}

// Absent keys should still receive defaults — flipping the layering order
// must not regress the common case of "I didn't set this; give me sane defaults".
func TestLoadConfig_AbsentKeysGetDefaults(t *testing.T) {
	path := writeConfig(t, `
[teleport]
addr          = "proxy.example.com:443"
identity_file = "/tmp/ruth.pem"

[ai]
provider = "anthropic"

[ai.anthropic]
api_key = "sk-test"

[jira]
url       = "https://example.atlassian.net"
username  = "bot@example.com"
api_token = "token"
project   = "ITSM"
`)

	cfg, err := LoadConfig(path)
	if err != nil {
		t.Fatalf("LoadConfig: %v", err)
	}

	if got, want := cfg.Risk.TicketThreshold, 25; got != want {
		t.Errorf("Risk.TicketThreshold = %d, want %d (default)", got, want)
	}
	if got, want := cfg.Risk.EscalationThreshold, 15; got != want {
		t.Errorf("Risk.EscalationThreshold = %d, want %d (default)", got, want)
	}
	if got, want := cfg.Reputation.PenaltyMissingCUSTX, 10; got != want {
		t.Errorf("PenaltyMissingCUSTX = %d, want %d (default)", got, want)
	}
	if got, want := cfg.Reputation.StartingScore, 100; got != want {
		t.Errorf("StartingScore = %d, want %d (default)", got, want)
	}
	if got, want := cfg.Log.Level, "info"; got != want {
		t.Errorf("Log.Level = %q, want %q (default)", got, want)
	}
}

// Env var overrides still take precedence over both TOML and defaults,
// including when the TOML value is an explicit zero.
func TestLoadConfig_EnvBeatsTOMLZero(t *testing.T) {
	t.Setenv("RUTH_RISK_TICKET_THRESHOLD", "42")
	path := writeConfig(t, `
[teleport]
addr          = "proxy.example.com:443"
identity_file = "/tmp/ruth.pem"

[ai]
provider = "anthropic"

[ai.anthropic]
api_key = "sk-test"

[jira]
url       = "https://example.atlassian.net"
username  = "bot@example.com"
api_token = "token"
project   = "ITSM"

[risk]
ticket_threshold = 0
`)

	cfg, err := LoadConfig(path)
	if err != nil {
		t.Fatalf("LoadConfig: %v", err)
	}
	if cfg.Risk.TicketThreshold != 42 {
		t.Errorf("Risk.TicketThreshold = %d, want 42 (env > TOML > default)", cfg.Risk.TicketThreshold)
	}
}


1	package ruth
2
3	import (
4		"testing"
5		"time"
6
7		"ruth/ai"
8		"ruth/jira"
9		"ruth/reputation"
10		"ruth/risk"
11		"ruth/telemetry"
12	)
13
14	func TestApplyEnv_Overrides(t *testing.T) {
15		t.Setenv("RUTH_DRY_RUN", "true")
16		t.Setenv("RUTH_TELEPORT_ADDR", "env-proxy.example.com:443")
17		t.Setenv("RUTH_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
18		t.Setenv("RUTH_AI_PROVIDER", "bedrock")
19		t.Setenv("RUTH_AI_ANTHROPIC_API_KEY", "sk-from-env")
20		t.Setenv("RUTH_AI_BEDROCK_REGION", "us-east-2")
21		t.Setenv("RUTH_RISK_TICKET_THRESHOLD", "42")
22		t.Setenv("RUTH_RISK_ESCALATION_THRESHOLD", "7")
23		t.Setenv("RUTH_JIRA_POLL_INTERVAL", "2m")
24		t.Setenv("RUTH_TELEMETRY_ENABLED", "true")
25		t.Setenv("RUTH_REPUTATION_STARTING_SCORE", "80")
26
27		cfg := &Config{
28			DryRun:   false,
29			Teleport: TeleportConfig{Addr: "old:443", IdentityFile: "/old.pem"},
30			AI: ai.Config{
31				Provider:  "anthropic",
32				Anthropic: ai.AnthropicConfig{APIKey: "sk-old"},
33				Bedrock:   ai.BedrockConfig{Region: "us-west-2"},
34			},
35			Risk:       risk.Config{TicketThreshold: 25, EscalationThreshold: 15},
36			Jira:       jira.Config{PollInterval: 30 * time.Second},
37			Telemetry:  telemetry.Config{Enabled: false},
38			Reputation: reputation.Config{StartingScore: 100},
39		}
40
41		if err := applyEnv(cfg); err != nil {
42			t.Fatalf("applyEnv: %v", err)
43		}
44
45		checks := []struct {
46			name string
47			got  any
48			want any
49		}{
50			{"dry_run", cfg.DryRun, true},
51			{"teleport.addr", cfg.Teleport.Addr, "env-proxy.example.com:443"},
52			{"teleport.identity_file", cfg.Teleport.IdentityFile, "/env/identity.pem"},
53			{"ai.provider", cfg.AI.Provider, "bedrock"},
54			{"ai.anthropic.api_key", cfg.AI.Anthropic.APIKey, "sk-from-env"},
55			{"ai.bedrock.region", cfg.AI.Bedrock.Region, "us-east-2"},
56			{"risk.ticket_threshold", cfg.Risk.TicketThreshold, 42},
57			{"risk.escalation_threshold", cfg.Risk.EscalationThreshold, 7},
58			{"jira.poll_interval", cfg.Jira.PollInterval, 2 * time.Minute},
59			{"telemetry.enabled", cfg.Telemetry.Enabled, true},
60			{"reputation.starting_score", cfg.Reputation.StartingScore, 80},
61		}
62		for _, c := range checks {
63			if c.got != c.want {
64				t.Errorf("%s = %v, want %v", c.name, c.got, c.want)
65			}
66		}
67	}
68
69	func TestApplyEnv_NoEnvLeavesConfigUntouched(t *testing.T) {
70		cfg := &Config{
71			Teleport: TeleportConfig{Addr: "untouched:443"},
72			Risk:     risk.Config{TicketThreshold: 25},
73		}
74		if err := applyEnv(cfg); err != nil {
75			t.Fatalf("applyEnv: %v", err)
76		}
77		if cfg.Teleport.Addr != "untouched:443" {
78			t.Errorf("Addr = %q, want untouched:443", cfg.Teleport.Addr)
79		}
80		if cfg.Risk.TicketThreshold != 25 {
81			t.Errorf("TicketThreshold = %d, want 25", cfg.Risk.TicketThreshold)
82		}
83	}
84
85	func TestApplyEnv_InvalidValue(t *testing.T) {
86		t.Setenv("RUTH_RISK_TICKET_THRESHOLD", "not-a-number")
87		cfg := &Config{Risk: risk.Config{TicketThreshold: 25}}
88		err := applyEnv(cfg)
89		if err == nil {
90			t.Fatal("applyEnv: expected error for non-numeric override, got nil")
91		}
92		if !contains(err.Error(), "RUTH_RISK_TICKET_THRESHOLD") {
93			t.Errorf("error should name the offending env var, got: %v", err)
94		}
95	}
96
97	func TestApplyEnv_MapFieldSkipped(t *testing.T) {
98		// Maps can't be expressed as a single env var; the helper must skip
99		// them silently rather than choke on an env var aimed at one.
100		t.Setenv("RUTH_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")
101		cfg := &Config{
102			Risk: risk.Config{
103				EscalationThresholds: map[string]int{"sre-admin": 5},
104			},
105		}
106		if err := applyEnv(cfg); err != nil {
107			t.Fatalf("applyEnv: %v", err)
108		}
109		if got := cfg.Risk.EscalationThresholds["sre-admin"]; got != 5 {
110			t.Errorf("EscalationThresholds[sre-admin] = %d, want 5", got)
111		}
112	}
113
114	func contains(haystack, needle string) bool {
115		for i := 0; i+len(needle) <= len(haystack); i++ {
116			if haystack[i:i+len(needle)] == needle {
117				return true
118			}
119		}
120		return false
121	}
122

package ruth

import (
	"testing"
	"time"

	"ruth/ai"
	"ruth/jira"
	"ruth/reputation"
	"ruth/risk"
	"ruth/telemetry"
)

func TestApplyEnv_Overrides(t *testing.T) {
	t.Setenv("RUTH_DRY_RUN", "true")
	t.Setenv("RUTH_TELEPORT_ADDR", "env-proxy.example.com:443")
	t.Setenv("RUTH_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
	t.Setenv("RUTH_AI_PROVIDER", "bedrock")
	t.Setenv("RUTH_AI_ANTHROPIC_API_KEY", "sk-from-env")
	t.Setenv("RUTH_AI_BEDROCK_REGION", "us-east-2")
	t.Setenv("RUTH_RISK_TICKET_THRESHOLD", "42")
	t.Setenv("RUTH_RISK_ESCALATION_THRESHOLD", "7")
	t.Setenv("RUTH_JIRA_POLL_INTERVAL", "2m")
	t.Setenv("RUTH_TELEMETRY_ENABLED", "true")
	t.Setenv("RUTH_REPUTATION_STARTING_SCORE", "80")

	cfg := &Config{
		DryRun:   false,
		Teleport: TeleportConfig{Addr: "old:443", IdentityFile: "/old.pem"},
		AI: ai.Config{
			Provider:  "anthropic",
			Anthropic: ai.AnthropicConfig{APIKey: "sk-old"},
			Bedrock:   ai.BedrockConfig{Region: "us-west-2"},
		},
		Risk:       risk.Config{TicketThreshold: 25, EscalationThreshold: 15},
		Jira:       jira.Config{PollInterval: 30 * time.Second},
		Telemetry:  telemetry.Config{Enabled: false},
		Reputation: reputation.Config{StartingScore: 100},
	}

	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}

	checks := []struct {
		name string
		got  any
		want any
	}{
		{"dry_run", cfg.DryRun, true},
		{"teleport.addr", cfg.Teleport.Addr, "env-proxy.example.com:443"},
		{"teleport.identity_file", cfg.Teleport.IdentityFile, "/env/identity.pem"},
		{"ai.provider", cfg.AI.Provider, "bedrock"},
		{"ai.anthropic.api_key", cfg.AI.Anthropic.APIKey, "sk-from-env"},
		{"ai.bedrock.region", cfg.AI.Bedrock.Region, "us-east-2"},
		{"risk.ticket_threshold", cfg.Risk.TicketThreshold, 42},
		{"risk.escalation_threshold", cfg.Risk.EscalationThreshold, 7},
		{"jira.poll_interval", cfg.Jira.PollInterval, 2 * time.Minute},
		{"telemetry.enabled", cfg.Telemetry.Enabled, true},
		{"reputation.starting_score", cfg.Reputation.StartingScore, 80},
	}
	for _, c := range checks {
		if c.got != c.want {
			t.Errorf("%s = %v, want %v", c.name, c.got, c.want)
		}
	}
}

func TestApplyEnv_NoEnvLeavesConfigUntouched(t *testing.T) {
	cfg := &Config{
		Teleport: TeleportConfig{Addr: "untouched:443"},
		Risk:     risk.Config{TicketThreshold: 25},
	}
	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}
	if cfg.Teleport.Addr != "untouched:443" {
		t.Errorf("Addr = %q, want untouched:443", cfg.Teleport.Addr)
	}
	if cfg.Risk.TicketThreshold != 25 {
		t.Errorf("TicketThreshold = %d, want 25", cfg.Risk.TicketThreshold)
	}
}

func TestApplyEnv_InvalidValue(t *testing.T) {
	t.Setenv("RUTH_RISK_TICKET_THRESHOLD", "not-a-number")
	cfg := &Config{Risk: risk.Config{TicketThreshold: 25}}
	err := applyEnv(cfg)
	if err == nil {
		t.Fatal("applyEnv: expected error for non-numeric override, got nil")
	}
	if !contains(err.Error(), "RUTH_RISK_TICKET_THRESHOLD") {
		t.Errorf("error should name the offending env var, got: %v", err)
	}
}

func TestApplyEnv_MapFieldSkipped(t *testing.T) {
	// Maps can't be expressed as a single env var; the helper must skip
	// them silently rather than choke on an env var aimed at one.
	t.Setenv("RUTH_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")
	cfg := &Config{
		Risk: risk.Config{
			EscalationThresholds: map[string]int{"sre-admin": 5},
		},
	}
	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}
	if got := cfg.Risk.EscalationThresholds["sre-admin"]; got != 5 {
		t.Errorf("EscalationThresholds[sre-admin] = %d, want 5", got)
	}
}

func contains(haystack, needle string) bool {
	for i := 0; i+len(needle) <= len(haystack); i++ {
		if haystack[i:i+len(needle)] == needle {
			return true
		}
	}
	return false
}


1	package ai
2
3	import (
4		"context"
5		"fmt"
6
7		"github.com/aws/aws-sdk-go-v2/aws"
8		awsconfig "github.com/aws/aws-sdk-go-v2/config"
9		"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
10		"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
11		"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
12		"github.com/aws/aws-sdk-go-v2/service/sts"
13	)
14
15	type BedrockConfig struct {
16		Region  string `toml:"region"`
17		ModelID string `toml:"model_id"`
18
19		// Profile, when set, selects a named profile from the shared AWS config
20		// and credentials files instead of the default chain. Handy for local
21		// dev; leave empty in prod so IRSA / Pod Identity take over.
22		Profile string `toml:"profile"`
23
24		// RoleARN, when set, wraps the resolved base credentials with an
25		// AssumeRole call. Use for cross-account setups where IRSA grants a
26		// role in one account and Bedrock lives in another. Base credentials
27		// (from IRSA, profile, env, etc.) must be allowed to sts:AssumeRole it.
28		RoleARN         string `toml:"role_arn"`
29		RoleSessionName string `toml:"role_session_name"`
30		ExternalID      string `toml:"external_id"`
31	}
32
33	type Bedrock struct {
34		cfg    BedrockConfig
35		client *bedrockruntime.Client
36	}
37
38	func NewBedrock(cfg BedrockConfig) (*Bedrock, error) {
39		if cfg.ModelID == "" {
40			cfg.ModelID = "us.anthropic.claude-sonnet-4-6-v1:0"
41		}
42
43		opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(cfg.Region)}
44		if cfg.Profile != "" {
45			opts = append(opts, awsconfig.WithSharedConfigProfile(cfg.Profile))
46		}
47
48		awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
49		if err != nil {
50			return nil, fmt.Errorf("aws config: %w", err)
51		}
52
53		if cfg.RoleARN != "" {
54			provider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(awsCfg), cfg.RoleARN, func(o *stscreds.AssumeRoleOptions) {
55				if cfg.RoleSessionName != "" {
56					o.RoleSessionName = cfg.RoleSessionName
57				}
58				if cfg.ExternalID != "" {
59					o.ExternalID = aws.String(cfg.ExternalID)
60				}
61			})
62			awsCfg.Credentials = aws.NewCredentialsCache(provider)
63		}
64
65		return &Bedrock{cfg: cfg, client: bedrockruntime.NewFromConfig(awsCfg)}, nil
66	}
67
68	func (b *Bedrock) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
69		out, err := b.client.Converse(ctx, &bedrockruntime.ConverseInput{
70			ModelId: aws.String(b.cfg.ModelID),
71			System: []types.SystemContentBlock{
72				&types.SystemContentBlockMemberText{Value: SystemPrompt},
73			},
74			Messages: []types.Message{{
75				Role: types.ConversationRoleUser,
76				Content: []types.ContentBlock{
77					&types.ContentBlockMemberText{Value: UserPrompt(req)},
78				},
79			}},
80			InferenceConfig: &types.InferenceConfiguration{
81				MaxTokens: aws.Int32(1024),
82			},
83		})
84		if err != nil {
85			return nil, fmt.Errorf("bedrock converse: %w", err)
86		}
87
88		msg, ok := out.Output.(*types.ConverseOutputMemberMessage)
89		if !ok {
90			return nil, fmt.Errorf("bedrock: unexpected output variant %T", out.Output)
91		}
92		for _, block := range msg.Value.Content {
93			if t, ok := block.(*types.ContentBlockMemberText); ok {
94				return parseAssessment(t.Value)
95			}
96		}
97		return nil, fmt.Errorf("no text content in bedrock response")
98	}
99

package ai

import (
	"context"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/aws"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
	"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
	"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
	"github.com/aws/aws-sdk-go-v2/service/sts"
)

type BedrockConfig struct {
	Region  string `toml:"region"`
	ModelID string `toml:"model_id"`

	// Profile, when set, selects a named profile from the shared AWS config
	// and credentials files instead of the default chain. Handy for local
	// dev; leave empty in prod so IRSA / Pod Identity take over.
	Profile string `toml:"profile"`

	// RoleARN, when set, wraps the resolved base credentials with an
	// AssumeRole call. Use for cross-account setups where IRSA grants a
	// role in one account and Bedrock lives in another. Base credentials
	// (from IRSA, profile, env, etc.) must be allowed to sts:AssumeRole it.
	RoleARN         string `toml:"role_arn"`
	RoleSessionName string `toml:"role_session_name"`
	ExternalID      string `toml:"external_id"`
}

type Bedrock struct {
	cfg    BedrockConfig
	client *bedrockruntime.Client
}

func NewBedrock(cfg BedrockConfig) (*Bedrock, error) {
	if cfg.ModelID == "" {
		cfg.ModelID = "us.anthropic.claude-sonnet-4-6-v1:0"
	}

	opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(cfg.Region)}
	if cfg.Profile != "" {
		opts = append(opts, awsconfig.WithSharedConfigProfile(cfg.Profile))
	}

	awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
	if err != nil {
		return nil, fmt.Errorf("aws config: %w", err)
	}

	if cfg.RoleARN != "" {
		provider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(awsCfg), cfg.RoleARN, func(o *stscreds.AssumeRoleOptions) {
			if cfg.RoleSessionName != "" {
				o.RoleSessionName = cfg.RoleSessionName
			}
			if cfg.ExternalID != "" {
				o.ExternalID = aws.String(cfg.ExternalID)
			}
		})
		awsCfg.Credentials = aws.NewCredentialsCache(provider)
	}

	return &Bedrock{cfg: cfg, client: bedrockruntime.NewFromConfig(awsCfg)}, nil
}

func (b *Bedrock) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
	out, err := b.client.Converse(ctx, &bedrockruntime.ConverseInput{
		ModelId: aws.String(b.cfg.ModelID),
		System: []types.SystemContentBlock{
			&types.SystemContentBlockMemberText{Value: SystemPrompt},
		},
		Messages: []types.Message{{
			Role: types.ConversationRoleUser,
			Content: []types.ContentBlock{
				&types.ContentBlockMemberText{Value: UserPrompt(req)},
			},
		}},
		InferenceConfig: &types.InferenceConfiguration{
			MaxTokens: aws.Int32(1024),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("bedrock converse: %w", err)
	}

	msg, ok := out.Output.(*types.ConverseOutputMemberMessage)
	if !ok {
		return nil, fmt.Errorf("bedrock: unexpected output variant %T", out.Output)
	}
	for _, block := range msg.Value.Content {
		if t, ok := block.(*types.ContentBlockMemberText); ok {
			return parseAssessment(t.Value)
		}
	}
	return nil, fmt.Errorf("no text content in bedrock response")
}


1	# Local test harness
2
3	A docker-compose stack for exercising ruth end-to-end without real
4	Teleport, Anthropic, or Jira credentials.
5
6	## What runs where
7
8	| Component      | Where  | Notes                                                                    |
9	| -------------- | ------ | ------------------------------------------------------------------------ |
10	| Teleport       | docker | Single-node auth+proxy cluster, self-signed TLS, port `3080`             |
11	| OTel collector | docker | OTLP/gRPC on `4317`, prints metrics to stdout via the debug exporter     |
12	| AI scoring     | inproc | `random` provider in `local/ruth.toml` fabricates assessments        |
13	| ruth       | host   | `go run ./cmd/ruth --config local/ruth.toml`                     |
14	| submit         | host   | `go run ./cmd/submit --reason "…"` fires test access requests            |
15
16	Jira is stubbed: `risk.ticket_threshold` is cranked to `101` in
17	`local/ruth.toml`, so nothing ever hits Jira. Reputation scoring runs
18	before that branch, so the test loop is intact.
19
20	## One-time setup
21
22	```sh
23	# from apps/ruth
24	./local/bootstrap.sh
25	```
26
27	This brings the stack up, applies the roles + users from
28	`local/resources.yaml`, and writes identity files to `local/identity/` for
29	the `ruth` plugin user and the `charles` test requester. Identities
30	are TTL 24h — re-run the script when they expire.
31
32	## Running
33
34	Three terminals:
35
36	```sh
37	# 1. watch metrics flow past
38	docker compose -f local/docker-compose.yml logs -f otel-collector
39
40	# 2. run ruth against the local cluster
41	go run ./cmd/ruth --config local/ruth.toml
42
43	# 3. fire test access requests
44	go run ./cmd/submit --reason "plz"
45	go run ./cmd/submit --reason "CUSTX-1234 debug prod-api p95 spike, repro in ticket"
46	```
47
48	## What you should see
49
50	- **ruth logs** — a `reputation docked` line for the "plz" request
51	  (no CUSTX reference, <40 chars → both penalties), no line for the
52	  well-formed one.
53	- **otel-collector logs** — every 5 seconds, a `ruth.user.reputation`
54	  gauge emitted for each user that has been scored, tagged with `user`.
55	  Charles' score should start at `100` and drop by `15` (10 + 5) per bad
56	  request, floored at `0`.
57	- **local/reputation.json** — the persisted state. Delete it to start fresh.
58
59	## Tearing down
60
61	```sh
62	docker compose -f local/docker-compose.yml down -v
63	rm -rf local/identity local/reputation.json
64	```
65
66	## Troubleshooting
67
68	- **`tctl status` times out during bootstrap** — bump the 60s wait in
69	  `bootstrap.sh`. First boot downloads the teleport image (~300MB).
70	- **`connect: context deadline exceeded` from submit** — teleport proxy
71	  not listening yet, or port 3080 collision. `lsof -i :3080` to check.
72	- **No metrics in the collector logs** — confirm `telemetry.enabled = true`
73	  in `local/ruth.toml` and that the gauge callback has users to
74	  observe (i.e., at least one request has been scored since startup;
75	  empty stores emit nothing).
76

# Local test harness

A docker-compose stack for exercising ruth end-to-end without real
Teleport, Anthropic, or Jira credentials.

## What runs where

| Component      | Where  | Notes                                                                    |
| -------------- | ------ | ------------------------------------------------------------------------ |
| Teleport       | docker | Single-node auth+proxy cluster, self-signed TLS, port `3080`             |
| OTel collector | docker | OTLP/gRPC on `4317`, prints metrics to stdout via the debug exporter     |
| AI scoring     | inproc | `random` provider in `local/ruth.toml` fabricates assessments        |
| ruth       | host   | `go run ./cmd/ruth --config local/ruth.toml`                     |
| submit         | host   | `go run ./cmd/submit --reason "…"` fires test access requests            |

Jira is stubbed: `risk.ticket_threshold` is cranked to `101` in
`local/ruth.toml`, so nothing ever hits Jira. Reputation scoring runs
before that branch, so the test loop is intact.

## One-time setup

```sh
# from apps/ruth
./local/bootstrap.sh
```

This brings the stack up, applies the roles + users from
`local/resources.yaml`, and writes identity files to `local/identity/` for
the `ruth` plugin user and the `charles` test requester. Identities
are TTL 24h — re-run the script when they expire.

## Running

Three terminals:

```sh
# 1. watch metrics flow past
docker compose -f local/docker-compose.yml logs -f otel-collector

# 2. run ruth against the local cluster
go run ./cmd/ruth --config local/ruth.toml

# 3. fire test access requests
go run ./cmd/submit --reason "plz"
go run ./cmd/submit --reason "CUSTX-1234 debug prod-api p95 spike, repro in ticket"
```

## What you should see

- **ruth logs** — a `reputation docked` line for the "plz" request
  (no CUSTX reference, <40 chars → both penalties), no line for the
  well-formed one.
- **otel-collector logs** — every 5 seconds, a `ruth.user.reputation`
  gauge emitted for each user that has been scored, tagged with `user`.
  Charles' score should start at `100` and drop by `15` (10 + 5) per bad
  request, floored at `0`.
- **local/reputation.json** — the persisted state. Delete it to start fresh.

## Tearing down

```sh
docker compose -f local/docker-compose.yml down -v
rm -rf local/identity local/reputation.json
```

## Troubleshooting

- **`tctl status` times out during bootstrap** — bump the 60s wait in
  `bootstrap.sh`. First boot downloads the teleport image (~300MB).
- **`connect: context deadline exceeded` from submit** — teleport proxy
  not listening yet, or port 3080 collision. `lsof -i :3080` to check.
- **No metrics in the collector logs** — confirm `telemetry.enabled = true`
  in `local/ruth.toml` and that the gauge callback has users to
  observe (i.e., at least one request has been scored since startup;
  empty stores emit nothing).


Let me also check `local/ruth.toml` since the local stack uses its own config.