The file /Users/steve.briggs/projects/operations/apps/televibe/config_test.go has been updated. All occurrences were successfully replaced.

package televibe

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

func writeConfig(t *testing.T, body string) string {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "televibe.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/televibe.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/televibe.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("TV_RISK_TICKET_THRESHOLD", "42")
	path := writeConfig(t, `
[teleport]
addr          = "proxy.example.com:443"
identity_file = "/tmp/televibe.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)
	}
}

-package televibe
+package ruth
import (
"os"

-  path := filepath.Join(dir, "televibe.toml")

+  path := filepath.Join(dir, "ruth.toml")


The file /Users/steve.briggs/projects/operations/apps/televibe/app.go has been updated. All occurrences were successfully replaced.

package televibe

import (
	"context"
	"fmt"
	"log/slog"
	"sync"
	"time"

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"golang.org/x/sync/errgroup"

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

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer
	rep    *reputation.Store
	meter  *telemetry.Meter

	pending   map[string]string
	pendingMu sync.Mutex
}

func NewApp(ctx context.Context, cfg *Config) (*App, error) {
	tele, err := client.New(ctx, client.Config{
		Addrs: []string{cfg.Teleport.Addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(cfg.Teleport.IdentityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("teleport client: %w", err)
	}

	prov, err := ai.New(cfg.AI)
	if err != nil {
		return nil, fmt.Errorf("ai provider: %w", err)
	}

	jc, err := jira.New(cfg.Jira)
	if err != nil {
		return nil, fmt.Errorf("jira client: %w", err)
	}

	scorer, err := risk.NewScorer(cfg.Risk)
	if err != nil {
		return nil, fmt.Errorf("scorer: %w", err)
	}

	store, err := reputation.NewStore(cfg.Reputation)
	if err != nil {
		return nil, fmt.Errorf("reputation store: %w", err)
	}

	var meter *telemetry.Meter
	if cfg.Telemetry.Enabled {
		meter, err = telemetry.New(ctx, cfg.Telemetry, store)
		if err != nil {
			return nil, fmt.Errorf("telemetry: %w", err)
		}
	}

	return &App{
		cfg:     cfg,
		tele:    tele,
		ai:      prov,
		jira:    jc,
		scorer:  scorer,
		rep:     store,
		meter:   meter,
		pending: map[string]string{},
	}, nil
}

// Close flushes outstanding telemetry. Safe to call even if telemetry
// is disabled.
func (a *App) Close(ctx context.Context) error {
	return a.meter.Shutdown(ctx)
}

func (a *App) Run(ctx context.Context) error {
	slog.Info("televibe starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project,
		"dry_run", a.cfg.DryRun)
	if a.cfg.DryRun {
		slog.Warn("dry-run enabled: no Teleport reviews or Jira tickets will be submitted")
	}

	if err := a.rehydrate(ctx); err != nil {
		return fmt.Errorf("rehydrate open ITSM tickets: %w", err)
	}

	// Run watcher/poller under their own cancelable context rather than
	// bolting them directly to the signal context. A coordinator
	// goroutine waits for the signal, then sleeps for
	// telemetry.shutdown_grace before cancelling the errgroup so that
	// the OTel periodic reader can finish an in-flight export cleanly.
	runCtx, cancelRun := context.WithCancel(context.Background())
	defer cancelRun()
	g, gctx := errgroup.WithContext(runCtx)
	g.Go(func() error { return a.watchRequests(gctx) })
	g.Go(func() error { return a.pollITSM(gctx) })
	g.Go(func() error {
		select {
		case <-ctx.Done():
			grace := time.Duration(0)
			if a.cfg.Telemetry.Enabled {
				grace = a.cfg.Telemetry.ShutdownGrace
			}
			if grace > 0 {
				slog.Info("shutdown signal received; waiting for telemetry flush", "grace", grace)
				select {
				case <-time.After(grace):
				case <-gctx.Done():
					// errgroup collapsed for some other reason; stop waiting.
				}
			}
			cancelRun()
		case <-gctx.Done():
		}
		return nil
	})

	return g.Wait()
}

func (a *App) rehydrate(ctx context.Context) error {
	open, err := a.jira.ListOpenTeleportTickets(ctx)
	if err != nil {
		return err
	}
	a.pendingMu.Lock()
	for reqID, ticket := range open {
		a.pending[reqID] = ticket
	}
	a.pendingMu.Unlock()
	slog.Info("rehydrated pending tickets", "count", len(open))
	return nil
}

func (a *App) watchRequests(ctx context.Context) error {
	watcher, err := a.tele.NewWatcher(ctx, types.Watch{
		Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
	})
	if err != nil {
		return fmt.Errorf("start watcher: %w", err)
	}
	defer watcher.Close()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watcher.Done():
			return watcher.Error()
		case ev := <-watcher.Events():
			if ev.Type != types.OpPut {
				continue
			}
			req, ok := ev.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			if !a.reserveOrSkip(req.GetName()) {
				continue
			}
			go a.handleRequest(ctx, req)
		}
	}
}

// reserveOrSkip atomically claims a slot for request id. Returns true if
// this caller won the race and should process the request; false if the
// id is already in flight or already has a ticket. Using the same map
// for both reservations and ticket mappings keeps dedup decisions on a
// single source of truth.
func (a *App) reserveOrSkip(id string) bool {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	if _, held := a.pending[id]; held {
		return false
	}
	a.pending[id] = ""
	return true
}

func (a *App) releasePending(id string) {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	delete(a.pending, id)
}

func (a *App) recordTicket(id, ticket string) {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	a.pending[id] = ticket
}

func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
	id := req.GetName()
	user := req.GetUser()
	log := slog.With("request_id", id, "user", user)

	// Release the reservation unless we successfully record a ticket;
	// that way score/Jira failures are re-tryable on the next OpPut and
	// auto-approved requests don't leave stale entries behind.
	handled := false
	defer func() {
		if !handled {
			a.releasePending(id)
		}
	}()

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"components", result.Components)

	assessment, err := a.rep.Assess(ctx, user, result.Components)
	if err != nil {
		log.Warn("reputation update failed", "err", err)
	}
	if len(assessment.Penalties) > 0 {
		reasons := make([]string, 0, len(assessment.Penalties))
		for _, p := range assessment.Penalties {
			reasons = append(reasons, p.Reason)
		}
		log.Info("reputation docked",
			"previous", assessment.Previous,
			"current", assessment.Score,
			"reasons", reasons)
	}

	threshold := a.cfg.Risk.TicketThreshold
	if result.Escalation {
		threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())
		log.Info("session escalation detected", "threshold", threshold)
	}

	if result.Score < threshold {
		reason := fmt.Sprintf("televibe: %s (score %d) — auto-approved",
			result.Tier.Name, result.Score)
		if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
			log.Error("auto-approve failed", "err", err)
			return
		}
		log.Info("access request auto-approved",
			"score", result.Score,
			"tier", result.Tier.Name,
			"threshold", threshold)
		return
	}

	if a.cfg.DryRun {
		log.Info("dry-run: would create ITSM ticket and hold request pending",
			"score", result.Score,
			"tier", result.Tier.Name,
			"threshold", threshold)
		return
	}

	ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
		RequestID:   id,
		User:        req.GetUser(),
		Roles:       req.GetRoles(),
		Reason:      req.GetRequestReason(),
		Score:       result.Score,
		Tier:        result.Tier.Name,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
		Escalation:  result.Escalation,
	})
	if err != nil {
		log.Error("create ITSM ticket failed", "err", err)
		return
	}

	a.recordTicket(id, ticket)
	handled = true
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

func (a *App) pollITSM(ctx context.Context) error {
	t := time.NewTicker(a.cfg.Jira.PollInterval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-t.C:
			a.reconcileITSM(ctx)
		}
	}
}

func (a *App) reconcileITSM(ctx context.Context) {
	a.pendingMu.Lock()
	snap := make(map[string]string, len(a.pending))
	for k, v := range a.pending {
		snap[k] = v
	}
	a.pendingMu.Unlock()

	for requestID, ticket := range snap {
		if ticket == "" {
			// Reservation for an in-flight handleRequest; nothing to poll yet.
			continue
		}
		status, err := a.jira.TicketStatus(ctx, ticket)
		if err != nil {
			slog.Warn("ITSM status check failed", "ticket", ticket, "err", err)
			continue
		}
		var next types.RequestState
		switch status {
		case jira.StatusApproved:
			next = types.RequestState_APPROVED
		case jira.StatusRejected:
			next = types.RequestState_DENIED
		default:
			continue
		}
		reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)
		if err := a.submitReview(ctx, requestID, next, reason); err != nil {
			slog.Error("submit review failed", "request_id", requestID, "err", err)
			continue
		}
		a.pendingMu.Lock()
		delete(a.pending, requestID)
		a.pendingMu.Unlock()
	}
}

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	if a.cfg.DryRun {
		slog.Info("dry-run: skipping AccessReview submission",
			"request_id", requestID,
			"state", state.String(),
			"reason", reason)
		return nil
	}
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}

-package televibe
+package ruth
import (
"context"

-    reason := fmt.Sprintf("televibe: %s (score %d) — auto-approved",

+    reason := fmt.Sprintf("ruth: %s (score %d) — auto-approved",

-    reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)

+    reason := fmt.Sprintf("ruth: ITSM %s resolved (%s)", ticket, status)


The file /Users/steve.briggs/projects/operations/apps/televibe/app_test.go has been updated. All occurrences were successfully replaced.

package televibe

import (
	"context"
	"sync"
	"sync/atomic"
	"testing"

	"github.com/gravitational/teleport/api/types"
)

func TestReserveOrSkip_ExactlyOneWinner(t *testing.T) {
	a := &App{pending: map[string]string{}}

	const goroutines = 100
	var start sync.WaitGroup
	var done sync.WaitGroup
	var winners atomic.Int32

	start.Add(1)
	done.Add(goroutines)
	for i := 0; i < goroutines; i++ {
		go func() {
			defer done.Done()
			start.Wait()
			if a.reserveOrSkip("req-1") {
				winners.Add(1)
			}
		}()
	}
	start.Done()
	done.Wait()

	if got := winners.Load(); got != 1 {
		t.Errorf("exactly one goroutine should reserve, got %d winners", got)
	}
	if _, ok := a.pending["req-1"]; !ok {
		t.Errorf("winning goroutine should have left a reservation in place")
	}
}

func TestReserveOrSkip_AfterReleaseReacquires(t *testing.T) {
	a := &App{pending: map[string]string{}}
	if !a.reserveOrSkip("req-1") {
		t.Fatal("first reservation should succeed")
	}
	if a.reserveOrSkip("req-1") {
		t.Fatal("second reservation should fail while held")
	}
	a.releasePending("req-1")
	if !a.reserveOrSkip("req-1") {
		t.Fatal("reservation after release should succeed (covers retry-after-failure path)")
	}
}

// In dry-run, submitReview must short-circuit before touching the Teleport
// client. With a nil client, a non-skipping path would panic, so the absence
// of a panic plus a nil error is the proof that the gate fired.
func TestSubmitReview_DryRunSkipsTeleportCall(t *testing.T) {
	a := &App{cfg: &Config{DryRun: true}, pending: map[string]string{}}
	if err := a.submitReview(context.Background(), "req-1", types.RequestState_APPROVED, "no cap"); err != nil {
		t.Fatalf("submitReview in dry-run: %v", err)
	}
}

func TestReserveOrSkip_TicketedIDIsNotReacquired(t *testing.T) {
	a := &App{pending: map[string]string{}}
	if !a.reserveOrSkip("req-1") {
		t.Fatal("reservation should succeed")
	}
	a.recordTicket("req-1", "ITSM-42")
	if a.reserveOrSkip("req-1") {
		t.Fatal("request already holding a ticket must not be reserved again")
	}
	if got := a.pending["req-1"]; got != "ITSM-42" {
		t.Errorf("recordTicket should leave the real key in place, got %q", got)
	}
}


The file /Users/steve.briggs/projects/operations/apps/televibe/env.go has been updated. All occurrences were successfully replaced.

package televibe

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 TV_BAR_FOO. Maps and slices are intentionally
// skipped — they don't map cleanly onto a single environment variable.
const envPrefix = "TV_"

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

// applyEnv overlays values from TV_-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
}

-package televibe
+package ruth
import (
"fmt"


lets rename the project from Televibe to RUTH


The file /Users/steve.briggs/projects/operations/apps/televibe/env_test.go has been updated. All occurrences were successfully replaced.

package televibe

import (
	"testing"
	"time"

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

func TestApplyEnv_Overrides(t *testing.T) {
	t.Setenv("TV_DRY_RUN", "true")
	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")
	t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
	t.Setenv("TV_AI_PROVIDER", "bedrock")
	t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")
	t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
	t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")
	t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")
	t.Setenv("TV_TELEMETRY_ENABLED", "true")
	t.Setenv("TV_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("TV_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(), "TV_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("TV_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
}


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


1	package risk
2
3	import (
4		"context"
5		"regexp"
6		"time"
7
8		"github.com/gravitational/teleport/api/types"
9
10		"televibe/ai"
11	)
12
13	type Result struct {
14		Score      int
15		Tier       Tier

package risk

import (
	"context"
	"regexp"
	"time"

	"github.com/gravitational/teleport/api/types"

	"televibe/ai"
)

type Result struct {
	Score      int
	Tier       Tier


The file /Users/steve.briggs/projects/operations/apps/televibe/risk/score.go has been updated. All occurrences were successfully replaced.

package risk

import (
	"context"
	"regexp"
	"time"

	"github.com/gravitational/teleport/api/types"

	"televibe/ai"
)

type Result struct {
	Score      int
	Tier       Tier
	Components map[string]int
	Reasoning  string
	Escalation bool
}

type Scorer struct {
	cfg        Config
	custxRegex *regexp.Regexp
	loc        *time.Location
}

func NewScorer(cfg Config) (*Scorer, error) {
	re, err := regexp.Compile(cfg.CUSTXPattern)
	if err != nil {
		return nil, err
	}
	loc, err := time.LoadLocation(cfg.Timezone)
	if err != nil {
		return nil, err
	}
	return &Scorer{cfg: cfg, custxRegex: re, loc: loc}, nil
}

func (s *Scorer) Score(ctx context.Context, prov ai.Provider, req types.AccessRequest) (*Result, error) {
	reason := req.GetRequestReason()
	hasCUSTX := s.custxRegex.MatchString(reason)
	descLen := len(reason)

	var resources []string
	for _, r := range req.GetRequestedResourceIDs() {
		resources = append(resources, r.Name)
	}

	assessment, err := prov.Assess(ctx, ai.ScoreRequest{
		User:              req.GetUser(),
		Roles:             req.GetRoles(),
		Resources:         resources,
		Reason:            reason,
		HasCUSTXReference: hasCUSTX,
		DescriptionLength: descLen,
	})
	if err != nil {
		return nil, err
	}

	components := map[string]int{}
	score := 0

	if !hasCUSTX {
		components["missing_custx_ticket"] = 30
		score += 30
	}
	if descLen < 40 {
		components["thin_description"] = 15
		score += 15
	}
	components["description_quality"] = clamp(assessment.DescriptionQuality, 0, 25)
	components["privilege_concern"] = clamp(assessment.PrivilegeConcern, 0, 25)
	components["ai_anomalies"] = clamp(assessment.Anomalies, 0, 10)
	score += components["description_quality"] + components["privilege_concern"] + components["ai_anomalies"]

	if s.isOffHours(time.Now()) {
		components["off_hours"] = 10
		score += 10
	}

	if score > 100 {
		score = 100
	}

	return &Result{
		Score:      score,
		Tier:       TierForScore(score),
		Components: components,
		Reasoning:  assessment.Reasoning,
		Escalation: IsEscalation(req, s.cfg.EscalationAnnotation),
	}, nil
}

// IsEscalation reports whether the access request carries the configured
// escalation annotation with a non-empty value.
func IsEscalation(req types.AccessRequest, key string) bool {
	if key == "" {
		return false
	}
	for _, v := range req.GetSystemAnnotations()[key] {
		if v != "" {
			return true
		}
	}
	return false
}

func (s *Scorer) isOffHours(t time.Time) bool {
	if s.cfg.OffHoursStart == 0 && s.cfg.OffHoursEnd == 0 {
		return false
	}
	h := t.In(s.loc).Hour()
	start, end := s.cfg.OffHoursStart, s.cfg.OffHoursEnd
	if start <= end {
		return h >= start && h < end
	}
	return h >= start || h < end
}

func clamp(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}


Rename Go module

Update go.mod module path from televibe to ruth

Rename package and imports

Update package declarations and imports in all .go files (televibe -> ruth)

Rename string identifiers and env prefix

Update string identifiers: env prefix TV_ -> RUTH_, AccessReview Author, telemetry service/meter/gauge names, escalation annotation default

Rename binary dir and config files

Rename binary dir cmd/televibe -> cmd/ruth and config files (televibe.toml*, local/televibe.toml) using git mv

Update docs and TOML comments

Update README.md, local/README.md, and TOML comments to reflect new name

Build and test post-rename

Run go build ./... and go test -race ./... to verify the rename is consistent


1	package risk
2
3	import (
4		"testing"
5
6		"github.com/gravitational/teleport/api/types"
7	)
8
9	func newReqWithAnnotations(t *testing.T, roles []string, annotations map[string][]string) types.AccessRequest {
10		t.Helper()
11		req, err := types.NewAccessRequest("test-req", "alice", roles...)
12		if err != nil {
13			t.Fatalf("new access request: %v", err)
14		}
15		if annotations != nil {
16			req.SetSystemAnnotations(annotations)
17		}
18		return req
19	}
20
21	func TestIsEscalation(t *testing.T) {
22		cases := []struct {
23			name        string
24			key         string
25			annotations map[string][]string
26			want        bool
27		}{
28			{name: "empty key", key: "", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: false},
29			{name: "no annotation", key: "televibe.escalation", annotations: nil, want: false},
30			{name: "key present, empty value", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {""}}, want: false},
31			{name: "key present with true", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: true},
32			{name: "key present with arbitrary non-empty", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"yes-please"}}, want: true},
33			{name: "unrelated key set", key: "televibe.escalation", annotations: map[string][]string{"other": {"true"}}, want: false},
34		}
35		for _, tc := range cases {
36			t.Run(tc.name, func(t *testing.T) {
37				req := newReqWithAnnotations(t, []string{"role-a"}, tc.annotations)
38				if got := IsEscalation(req, tc.key); got != tc.want {
39					t.Errorf("IsEscalation = %v, want %v", got, tc.want)
40				}
41			})
42		}
43	}
44
45	func TestEscalationThresholdFor(t *testing.T) {
46		cfg := Config{
47			EscalationThreshold: 15,
48			EscalationThresholds: map[string]int{
49				"sre-admin": 5,
50				"db-admin":  10,
51				"loose":     25,
52			},
53		}
54
55		cases := []struct {
56			name  string
57			roles []string
58			want  int
59		}{
60			{name: "no roles uses global", roles: nil, want: 15},
61			{name: "unknown role uses global", roles: []string{"unknown"}, want: 15},
62			{name: "single known role", roles: []string{"sre-admin"}, want: 5},
63			{name: "looser-than-global override is honoured in isolation", roles: []string{"loose"}, want: 25},
64			{name: "strictest among mixed wins", roles: []string{"loose", "db-admin", "sre-admin"}, want: 5},
65			{name: "known plus unknown picks min of override and global", roles: []string{"loose", "unknown"}, want: 15},
66		}
67
68		for _, tc := range cases {
69			t.Run(tc.name, func(t *testing.T) {
70				if got := cfg.EscalationThresholdFor(tc.roles); got != tc.want {
71					t.Errorf("EscalationThresholdFor(%v) = %d, want %d", tc.roles, got, tc.want)
72				}
73			})
74		}
75	}
76

package risk

import (
	"testing"

	"github.com/gravitational/teleport/api/types"
)

func newReqWithAnnotations(t *testing.T, roles []string, annotations map[string][]string) types.AccessRequest {
	t.Helper()
	req, err := types.NewAccessRequest("test-req", "alice", roles...)
	if err != nil {
		t.Fatalf("new access request: %v", err)
	}
	if annotations != nil {
		req.SetSystemAnnotations(annotations)
	}
	return req
}

func TestIsEscalation(t *testing.T) {
	cases := []struct {
		name        string
		key         string
		annotations map[string][]string
		want        bool
	}{
		{name: "empty key", key: "", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: false},
		{name: "no annotation", key: "televibe.escalation", annotations: nil, want: false},
		{name: "key present, empty value", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {""}}, want: false},
		{name: "key present with true", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: true},
		{name: "key present with arbitrary non-empty", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"yes-please"}}, want: true},
		{name: "unrelated key set", key: "televibe.escalation", annotations: map[string][]string{"other": {"true"}}, want: false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := newReqWithAnnotations(t, []string{"role-a"}, tc.annotations)
			if got := IsEscalation(req, tc.key); got != tc.want {
				t.Errorf("IsEscalation = %v, want %v", got, tc.want)
			}
		})
	}
}

func TestEscalationThresholdFor(t *testing.T) {
	cfg := Config{
		EscalationThreshold: 15,
		EscalationThresholds: map[string]int{
			"sre-admin": 5,
			"db-admin":  10,
			"loose":     25,
		},
	}

	cases := []struct {
		name  string
		roles []string
		want  int
	}{
		{name: "no roles uses global", roles: nil, want: 15},
		{name: "unknown role uses global", roles: []string{"unknown"}, want: 15},
		{name: "single known role", roles: []string{"sre-admin"}, want: 5},
		{name: "looser-than-global override is honoured in isolation", roles: []string{"loose"}, want: 25},
		{name: "strictest among mixed wins", roles: []string{"loose", "db-admin", "sre-admin"}, want: 5},
		{name: "known plus unknown picks min of override and global", roles: []string{"loose", "unknown"}, want: 15},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := cfg.EscalationThresholdFor(tc.roles); got != tc.want {
				t.Errorf("EscalationThresholdFor(%v) = %d, want %d", tc.roles, got, tc.want)
			}
		})
	}
}


The file /Users/steve.briggs/projects/operations/apps/televibe/risk/score_test.go has been updated. All occurrences were successfully replaced.

package risk

import (
	"testing"

	"github.com/gravitational/teleport/api/types"
)

func newReqWithAnnotations(t *testing.T, roles []string, annotations map[string][]string) types.AccessRequest {
	t.Helper()
	req, err := types.NewAccessRequest("test-req", "alice", roles...)
	if err != nil {
		t.Fatalf("new access request: %v", err)
	}
	if annotations != nil {
		req.SetSystemAnnotations(annotations)
	}
	return req
}

func TestIsEscalation(t *testing.T) {
	cases := []struct {
		name        string
		key         string
		annotations map[string][]string
		want        bool
	}{
		{name: "empty key", key: "", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: false},
		{name: "no annotation", key: "televibe.escalation", annotations: nil, want: false},
		{name: "key present, empty value", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {""}}, want: false},
		{name: "key present with true", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: true},
		{name: "key present with arbitrary non-empty", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"yes-please"}}, want: true},
		{name: "unrelated key set", key: "televibe.escalation", annotations: map[string][]string{"other": {"true"}}, want: false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := newReqWithAnnotations(t, []string{"role-a"}, tc.annotations)
			if got := IsEscalation(req, tc.key); got != tc.want {
				t.Errorf("IsEscalation = %v, want %v", got, tc.want)
			}
		})
	}
}

func TestEscalationThresholdFor(t *testing.T) {
	cfg := Config{
		EscalationThreshold: 15,
		EscalationThresholds: map[string]int{
			"sre-admin": 5,
			"db-admin":  10,
			"loose":     25,
		},
	}

	cases := []struct {
		name  string
		roles []string
		want  int
	}{
		{name: "no roles uses global", roles: nil, want: 15},
		{name: "unknown role uses global", roles: []string{"unknown"}, want: 15},
		{name: "single known role", roles: []string{"sre-admin"}, want: 5},
		{name: "looser-than-global override is honoured in isolation", roles: []string{"loose"}, want: 25},
		{name: "strictest among mixed wins", roles: []string{"loose", "db-admin", "sre-admin"}, want: 5},
		{name: "known plus unknown picks min of override and global", roles: []string{"loose", "unknown"}, want: 15},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := cfg.EscalationThresholdFor(tc.roles); got != tc.want {
				t.Errorf("EscalationThresholdFor(%v) = %d, want %d", tc.roles, got, tc.want)
			}
		})
	}
}

-    {name: "empty key", key: "", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: false},

-    {name: "no annotation", key: "televibe.escalation", annotations: nil, want: false},

-    {name: "key present, empty value", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {""}}, want: false},

-    {name: "key present with true", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: true},

-    {name: "key present with arbitrary non-empty", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"yes-please"}}, want: true},

-    {name: "unrelated key set", key: "televibe.escalation", annotations: map[string][]string{"other": {"true"}}, want: false},

+    {name: "empty key", key: "", annotations: map[string][]string{"ruth.escalation": {"true"}}, want: false},

+    {name: "no annotation", key: "ruth.escalation", annotations: nil, want: false},

+    {name: "key present, empty value", key: "ruth.escalation", annotations: map[string][]string{"ruth.escalation": {""}}, want: false},

+    {name: "key present with true", key: "ruth.escalation", annotations: map[string][]string{"ruth.escalation": {"true"}}, want: true},

+    {name: "key present with arbitrary non-empty", key: "ruth.escalation", annotations: map[string][]string{"ruth.escalation": {"yes-please"}}, want: true},

+    {name: "unrelated key set", key: "ruth.escalation", annotations: map[string][]string{"other": {"true"}}, want: false},


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 = "televibe.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 = "televibe.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
}


The file /Users/steve.briggs/projects/operations/apps/televibe/risk/risk.go has been updated successfully.

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 = "televibe.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
}

-    c.EscalationAnnotation = "televibe.escalation"

+    c.EscalationAnnotation = "ruth.escalation"


1	// Package telemetry wires an OpenTelemetry MeterProvider to an OTLP/gRPC
2	// collector and publishes per-user reputation as an observable gauge. The
3	// collector is expected to fan out to Datadog (or any other backend) via
4	// its own exporter pipeline.
5	package telemetry
6
7	import (
8		"context"
9		"fmt"
10		"time"
11
12		"go.opentelemetry.io/otel"
13		"go.opentelemetry.io/otel/attribute"
14		"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
15		"go.opentelemetry.io/otel/metric"
16		sdkmetric "go.opentelemetry.io/otel/sdk/metric"
17		"go.opentelemetry.io/otel/sdk/resource"
18		semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
19	)
20
21	type Config struct {
22		Enabled      bool          `toml:"enabled"`
23		Endpoint     string        `toml:"endpoint"`
24		Insecure     bool          `toml:"insecure"`
25		ServiceName  string        `toml:"service_name"`
26		ExportPeriod time.Duration `toml:"export_period"`
27
28		// ShutdownGrace is the delay between receiving SIGINT/SIGTERM and
29		// cancelling the watcher/poller context. It gives the OTel periodic
30		// reader time to finish an in-flight export before the app tears
31		// down; without it shutdowns emit a spurious "context canceled"
32		// error from the exporter. Set to 0 to disable.
33		ShutdownGrace time.Duration `toml:"shutdown_grace"`
34	}
35
36	func (c *Config) ApplyDefaults() {
37		if c.ServiceName == "" {
38			c.ServiceName = "televibe"
39		}
40		if c.ExportPeriod == 0 {
41			c.ExportPeriod = 15 * time.Second
42		}
43		if c.ShutdownGrace == 0 {
44			c.ShutdownGrace = 2 * time.Second
45		}
46	}
47
48	// ReputationSource is the minimum surface telemetry needs from the
49	// reputation store: iterate the users it currently knows about.
50	type ReputationSource interface {
51		Range(fn func(user string, score int))
52	}
53
54	type Meter struct {
55		provider *sdkmetric.MeterProvider
56		reg      metric.Registration
57	}
58
59	func New(ctx context.Context, cfg Config, src ReputationSource) (*Meter, error) {
60		opts := []otlpmetricgrpc.Option{}
61		if cfg.Endpoint != "" {
62			opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
63		}
64		if cfg.Insecure {
65			opts = append(opts, otlpmetricgrpc.WithInsecure())
66		}
67		exp, err := otlpmetricgrpc.New(ctx, opts...)
68		if err != nil {
69			return nil, fmt.Errorf("otlp exporter: %w", err)
70		}
71
72		res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(cfg.ServiceName)))
73		if err != nil {
74			return nil, fmt.Errorf("resource: %w", err)
75		}
76
77		provider := sdkmetric.NewMeterProvider(
78			sdkmetric.WithResource(res),
79			sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(cfg.ExportPeriod))),
80		)
81		otel.SetMeterProvider(provider)
82
83		meter := provider.Meter("televibe/reputation")
84		gauge, err := meter.Int64ObservableGauge(
85			"televibe.user.reputation",
86			metric.WithDescription("Per-user reputation score (lower = worse)"),
87		)
88		if err != nil {
89			_ = provider.Shutdown(ctx)
90			return nil, fmt.Errorf("register gauge: %w", err)
91		}
92
93		reg, err := meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
94			src.Range(func(user string, score int) {
95				o.ObserveInt64(gauge, int64(score), metric.WithAttributes(
96					attribute.String("user", user),
97				))
98			})
99			return nil
100		}, gauge)
101		if err != nil {
102			_ = provider.Shutdown(ctx)
103			return nil, fmt.Errorf("register callback: %w", err)
104		}
105
106		return &Meter{provider: provider, reg: reg}, nil
107	}
108
109	// Shutdown unregisters the callback and flushes pending metrics to the
110	// collector. Safe to call on a nil receiver.
111	func (m *Meter) Shutdown(ctx context.Context) error {
112		if m == nil || m.provider == nil {
113			return nil
114		}
115		_ = m.reg.Unregister()
116		return m.provider.Shutdown(ctx)
117	}
118

// Package telemetry wires an OpenTelemetry MeterProvider to an OTLP/gRPC
// collector and publishes per-user reputation as an observable gauge. The
// collector is expected to fan out to Datadog (or any other backend) via
// its own exporter pipeline.
package telemetry

import (
	"context"
	"fmt"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
	"go.opentelemetry.io/otel/metric"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
	semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

type Config struct {
	Enabled      bool          `toml:"enabled"`
	Endpoint     string        `toml:"endpoint"`
	Insecure     bool          `toml:"insecure"`
	ServiceName  string        `toml:"service_name"`
	ExportPeriod time.Duration `toml:"export_period"`

	// ShutdownGrace is the delay between receiving SIGINT/SIGTERM and
	// cancelling the watcher/poller context. It gives the OTel periodic
	// reader time to finish an in-flight export before the app tears
	// down; without it shutdowns emit a spurious "context canceled"
	// error from the exporter. Set to 0 to disable.
	ShutdownGrace time.Duration `toml:"shutdown_grace"`
}

func (c *Config) ApplyDefaults() {
	if c.ServiceName == "" {
		c.ServiceName = "televibe"
	}
	if c.ExportPeriod == 0 {
		c.ExportPeriod = 15 * time.Second
	}
	if c.ShutdownGrace == 0 {
		c.ShutdownGrace = 2 * time.Second
	}
}

// ReputationSource is the minimum surface telemetry needs from the
// reputation store: iterate the users it currently knows about.
type ReputationSource interface {
	Range(fn func(user string, score int))
}

type Meter struct {
	provider *sdkmetric.MeterProvider
	reg      metric.Registration
}

func New(ctx context.Context, cfg Config, src ReputationSource) (*Meter, error) {
	opts := []otlpmetricgrpc.Option{}
	if cfg.Endpoint != "" {
		opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
	}
	if cfg.Insecure {
		opts = append(opts, otlpmetricgrpc.WithInsecure())
	}
	exp, err := otlpmetricgrpc.New(ctx, opts...)
	if err != nil {
		return nil, fmt.Errorf("otlp exporter: %w", err)
	}

	res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(cfg.ServiceName)))
	if err != nil {
		return nil, fmt.Errorf("resource: %w", err)
	}

	provider := sdkmetric.NewMeterProvider(
		sdkmetric.WithResource(res),
		sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(cfg.ExportPeriod))),
	)
	otel.SetMeterProvider(provider)

	meter := provider.Meter("televibe/reputation")
	gauge, err := meter.Int64ObservableGauge(
		"televibe.user.reputation",
		metric.WithDescription("Per-user reputation score (lower = worse)"),
	)
	if err != nil {
		_ = provider.Shutdown(ctx)
		return nil, fmt.Errorf("register gauge: %w", err)
	}

	reg, err := meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
		src.Range(func(user string, score int) {
			o.ObserveInt64(gauge, int64(score), metric.WithAttributes(
				attribute.String("user", user),
			))
		})
		return nil
	}, gauge)
	if err != nil {
		_ = provider.Shutdown(ctx)
		return nil, fmt.Errorf("register callback: %w", err)
	}

	return &Meter{provider: provider, reg: reg}, nil
}

// Shutdown unregisters the callback and flushes pending metrics to the
// collector. Safe to call on a nil receiver.
func (m *Meter) Shutdown(ctx context.Context) error {
	if m == nil || m.provider == nil {
		return nil
	}
	_ = m.reg.Unregister()
	return m.provider.Shutdown(ctx)
}


The file /Users/steve.briggs/projects/operations/apps/televibe/telemetry/telemetry.go has been updated. All occurrences were successfully replaced.

// Package telemetry wires an OpenTelemetry MeterProvider to an OTLP/gRPC
// collector and publishes per-user reputation as an observable gauge. The
// collector is expected to fan out to Datadog (or any other backend) via
// its own exporter pipeline.
package telemetry

import (
	"context"
	"fmt"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
	"go.opentelemetry.io/otel/metric"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
	semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

type Config struct {
	Enabled      bool          `toml:"enabled"`
	Endpoint     string        `toml:"endpoint"`
	Insecure     bool          `toml:"insecure"`
	ServiceName  string        `toml:"service_name"`
	ExportPeriod time.Duration `toml:"export_period"`

	// ShutdownGrace is the delay between receiving SIGINT/SIGTERM and
	// cancelling the watcher/poller context. It gives the OTel periodic
	// reader time to finish an in-flight export before the app tears
	// down; without it shutdowns emit a spurious "context canceled"
	// error from the exporter. Set to 0 to disable.
	ShutdownGrace time.Duration `toml:"shutdown_grace"`
}

func (c *Config) ApplyDefaults() {
	if c.ServiceName == "" {
		c.ServiceName = "televibe"
	}
	if c.ExportPeriod == 0 {
		c.ExportPeriod = 15 * time.Second
	}
	if c.ShutdownGrace == 0 {
		c.ShutdownGrace = 2 * time.Second
	}
}

// ReputationSource is the minimum surface telemetry needs from the
// reputation store: iterate the users it currently knows about.
type ReputationSource interface {
	Range(fn func(user string, score int))
}

type Meter struct {
	provider *sdkmetric.MeterProvider
	reg      metric.Registration
}

func New(ctx context.Context, cfg Config, src ReputationSource) (*Meter, error) {
	opts := []otlpmetricgrpc.Option{}
	if cfg.Endpoint != "" {
		opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
	}
	if cfg.Insecure {
		opts = append(opts, otlpmetricgrpc.WithInsecure())
	}
	exp, err := otlpmetricgrpc.New(ctx, opts...)
	if err != nil {
		return nil, fmt.Errorf("otlp exporter: %w", err)
	}

	res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(cfg.ServiceName)))
	if err != nil {
		return nil, fmt.Errorf("resource: %w", err)
	}

	provider := sdkmetric.NewMeterProvider(
		sdkmetric.WithResource(res),
		sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(cfg.ExportPeriod))),
	)
	otel.SetMeterProvider(provider)

	meter := provider.Meter("televibe/reputation")
	gauge, err := meter.Int64ObservableGauge(
		"televibe.user.reputation",
		metric.WithDescription("Per-user reputation score (lower = worse)"),
	)
	if err != nil {
		_ = provider.Shutdown(ctx)
		return nil, fmt.Errorf("register gauge: %w", err)
	}

	reg, err := meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
		src.Range(func(user string, score int) {
			o.ObserveInt64(gauge, int64(score), metric.WithAttributes(
				attribute.String("user", user),
			))
		})
		return nil
	}, gauge)
	if err != nil {
		_ = provider.Shutdown(ctx)
		return nil, fmt.Errorf("register callback: %w", err)
	}

	return &Meter{provider: provider, reg: reg}, nil
}

// Shutdown unregisters the callback and flushes pending metrics to the
// collector. Safe to call on a nil receiver.
func (m *Meter) Shutdown(ctx context.Context) error {
	if m == nil || m.provider == nil {
		return nil
	}
	_ = m.reg.Unregister()
	return m.provider.Shutdown(ctx)
}

func (c *Config) ApplyDefaults() {
if c.ServiceName == "" {
-    c.ServiceName = "televibe"
+    c.ServiceName = "ruth"
}
if c.ExportPeriod == 0 {
c.ExportPeriod = 15 * time.Second

-  meter := provider.Meter("televibe/reputation")

+  meter := provider.Meter("ruth/reputation")


Now check the remaining Go files (jira, reputation, ai, cmd) for any straggling references.


Bash

grep -rln "televibe\|TV_" /Users/steve.briggs/projects/operations/apps/televibe --include="*.go"

Re-check remaining Go references after edits


Show remaining matches


/Users/steve.briggs/projects/operations/apps/televibe/env.go:14:// overridden by the env var TV_BAR_FOO. Maps and slices are intentionally
/Users/steve.briggs/projects/operations/apps/televibe/env.go:16:const envPrefix = "TV_"
/Users/steve.briggs/projects/operations/apps/televibe/env.go:20:// applyEnv overlays values from TV_-prefixed environment variables onto
/Users/steve.briggs/projects/operations/apps/televibe/config_test.go:115:	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
/Users/steve.briggs/projects/operations/apps/televibe/cmd/submit/main.go:2:// access request as a given user, so you can watch televibe score it.
/Users/steve.briggs/projects/operations/apps/televibe/cmd/televibe/main.go:13:	"televibe"
/Users/steve.briggs/projects/operations/apps/televibe/cmd/televibe/main.go:17:	configPath := flag.String("config", "televibe.toml", "Path to TOML configuration")
/Users/steve.briggs/projects/operations/apps/televibe/cmd/televibe/main.go:20:	cfg, err := televibe.LoadConfig(*configPath)
/Users/steve.briggs/projects/operations/apps/televibe/cmd/televibe/main.go:31:	app, err := televibe.NewApp(ctx, cfg)
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:15:	t.Setenv("TV_DRY_RUN", "true")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:16:	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:17:	t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:18:	t.Setenv("TV_AI_PROVIDER", "bedrock")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:19:	t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:20:	t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:21:	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:22:	t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:23:	t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:24:	t.Setenv("TV_TELEMETRY_ENABLED", "true")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:25:	t.Setenv("TV_REPUTATION_STARTING_SCORE", "80")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:86:	t.Setenv("TV_RISK_TICKET_THRESHOLD", "not-a-number")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:92:	if !contains(err.Error(), "TV_RISK_TICKET_THRESHOLD") {
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:100:	t.Setenv("TV_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")


The file /Users/steve.briggs/projects/operations/apps/televibe/cmd/televibe/main.go has been updated. All occurrences were successfully replaced.

package main

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

	"televibe"
)

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

	cfg, err := televibe.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 := televibe.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))
}

-  configPath := flag.String("config", "televibe.toml", "Path to TOML configuration")

+  configPath := flag.String("config", "ruth.toml", "Path to TOML configuration")

-  cfg, err := televibe.LoadConfig(*configPath)

+  cfg, err := ruth.LoadConfig(*configPath)

-  app, err := televibe.NewApp(ctx, cfg)


Rename Go module

Rename package and imports

Rename binary dir and config files

Update docs and TOML comments

Build and test post-rename


Edit

// access request as a given user, so you can watch televibe score it.

// access request as a given user, so you can watch ruth score it.


The file /Users/steve.briggs/projects/operations/apps/televibe/cmd/submit/main.go has been updated successfully.

// submit is a tiny helper for the local test harness — fires a Teleport
// access request as a given user, so you can watch televibe score it.
//
// Usage:
//
//	go run ./cmd/submit --reason "plz"
//	go run ./cmd/submit --reason "CUSTX-1234 fix prod-api latency regression"
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
)

func main() {
	addr := flag.String("addr", "localhost:3025", "teleport proxy address")
	identity := flag.String("identity", "./local/identity/charles.pem", "identity file")
	user := flag.String("user", "charles", "requesting user")
	roles := flag.String("roles", "privileged", "comma-separated roles to request")
	reason := flag.String("reason", "", "request reason")
	flag.Parse()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	c, err := client.New(ctx, client.Config{
		Addrs:                    []string{*addr},
		Credentials:              []client.Credentials{client.LoadIdentityFile(*identity)},
		InsecureAddressDiscovery: true,
	})
	if err != nil {
		log.Fatalf("connect: %v", err)
	}
	defer c.Close()

	roleList := strings.Split(*roles, ",")
	req, err := types.NewAccessRequest(uuid.NewString(), *user, roleList...)
	if err != nil {
		log.Fatalf("new access request: %v", err)
	}
	req.SetRequestReason(*reason)

	created, err := c.CreateAccessRequestV2(ctx, req)
	if err != nil {
		log.Fatalf("create access request: %v", err)
	}
	fmt.Printf("submitted request %s — user=%s roles=%v reason=%q\n",
		created.GetName(), *user, roleList, *reason)
}

-// access request as a given user, so you can watch televibe score it.

+// access request as a given user, so you can watch ruth score it.


Updated task #7 status


Updated task #8 status


Now the env prefix `TV_` → `RUTH_`:


The file /Users/steve.briggs/projects/operations/apps/televibe/env.go has been updated. All occurrences were successfully replaced.

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 TV_BAR_FOO. Maps and slices are intentionally
// skipped — they don't map cleanly onto a single environment variable.
const envPrefix = "TV_"

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

// applyEnv overlays values from TV_-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
}

-// overridden by the env var TV_BAR_FOO. Maps and slices are intentionally

+// overridden by the env var RUTH_BAR_FOO. Maps and slices are intentionally

-// applyEnv overlays values from TV_-prefixed environment variables onto

+// applyEnv overlays values from RUTH_-prefixed environment variables onto


The file /Users/steve.briggs/projects/operations/apps/televibe/env_test.go has been updated. All occurrences were successfully replaced.

package ruth

import (
	"testing"
	"time"

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

func TestApplyEnv_Overrides(t *testing.T) {
	t.Setenv("TV_DRY_RUN", "true")
	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")
	t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
	t.Setenv("TV_AI_PROVIDER", "bedrock")
	t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")
	t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
	t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")
	t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")
	t.Setenv("TV_TELEMETRY_ENABLED", "true")
	t.Setenv("TV_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("TV_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(), "TV_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("TV_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
}

-  t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")

-  t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")

-  t.Setenv("TV_AI_PROVIDER", "bedrock")

-  t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")

-  t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")

-  t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")

-  t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")

-  t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")

-  t.Setenv("TV_TELEMETRY_ENABLED", "true")

-  t.Setenv("TV_REPUTATION_STARTING_SCORE", "80")

+  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")

-  t.Setenv("TV_RISK_TICKET_THRESHOLD", "not-a-number")

+  t.Setenv("RUTH_RISK_TICKET_THRESHOLD", "not-a-number")

-  if !contains(err.Error(), "TV_RISK_TICKET_THRESHOLD") {

+  if !contains(err.Error(), "RUTH_RISK_TICKET_THRESHOLD") {

-  t.Setenv("TV_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")

+  t.Setenv("RUTH_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")


The file /Users/steve.briggs/projects/operations/apps/televibe/config_test.go has been updated. All occurrences were successfully replaced.

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("TV_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)
	}
}

-  t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")

+  t.Setenv("RUTH_RISK_TICKET_THRESHOLD", "42")


Bash

grep -rn "televibe\|TV_" /Users/steve.briggs/projects/operations/apps/televibe --include="*.go"

Confirm Go sources are clean


(Bash completed with no output)


Go sources clean. Now the TOML files.


1	# televibe — Teleport access plugin with AI risk scoring
2	# Copy to /etc/televibe/televibe.toml and fill in.
3
4	[teleport]
5	addr = "proxy.example.com:443"
6	identity_file = "/var/lib/teleport/televibe.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 = "televibe"
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/televibe/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  = "televibe"
65	export_period = 15
66
67	[log]
68	level = "info"
69

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

[teleport]
addr = "proxy.example.com:443"
identity_file = "/var/lib/teleport/televibe.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 = "televibe"

[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/televibe/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  = "televibe"
export_period = 15

[log]
level = "info"


The file /Users/steve.briggs/projects/operations/apps/televibe/televibe.toml has been updated. All occurrences were successfully replaced.

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

[teleport]
addr = "proxy.example.com:443"
identity_file = "/var/lib/teleport/televibe.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 = "televibe"

[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/televibe/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  = "televibe"
export_period = 15

[log]
level = "info"

-# televibe — Teleport access plugin with AI risk scoring

-# Copy to /etc/televibe/televibe.toml and fill in.

+# ruth — Teleport access plugin with AI risk scoring

+# Copy to /etc/ruth/ruth.toml and fill in.

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

-persist_path              = "/var/lib/televibe/reputation.json"

+persist_path              = "/var/lib/ruth/reputation.json"

enabled       = true
endpoint      = "otel-collector:4317"
insecure      = true
-service_name  = "televibe"
+service_name  = "ruth"
export_period = 15
[log]


1	# televibe — Teleport access plugin with AI risk scoring
2	# Copy to /etc/televibe/televibe.toml and fill in.
3	#
4	# Any scalar key can also be overridden via a TV_-prefixed environment
5	# variable (e.g. TV_TELEPORT_ADDR, TV_AI_ANTHROPIC_API_KEY,
6	# TV_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/televibe.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 = "televibe"
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 = "televibe.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/televibe/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   = "televibe"
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

# televibe — Teleport access plugin with AI risk scoring
# Copy to /etc/televibe/televibe.toml and fill in.
#
# Any scalar key can also be overridden via a TV_-prefixed environment
# variable (e.g. TV_TELEPORT_ADDR, TV_AI_ANTHROPIC_API_KEY,
# TV_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/televibe.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 = "televibe"
# 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 = "televibe.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/televibe/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   = "televibe"
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"


The file /Users/steve.briggs/projects/operations/apps/televibe/televibe.toml.example has been updated. All occurrences were successfully replaced.

# televibe — Teleport access plugin with AI risk scoring
# Copy to /etc/televibe/televibe.toml and fill in.
#
# Any scalar key can also be overridden via a TV_-prefixed environment
# variable (e.g. TV_TELEPORT_ADDR, TV_AI_ANTHROPIC_API_KEY,
# TV_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/televibe.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 = "televibe"
# 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 = "televibe.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/televibe/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   = "televibe"
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"


# 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 TV_-prefixed environment
# variable (e.g. TV_TELEPORT_ADDR, TV_AI_ANTHROPIC_API_KEY,
# TV_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"

-# Any scalar key can also be overridden via a TV_-prefixed environment

-# variable (e.g. TV_TELEPORT_ADDR, TV_AI_ANTHROPIC_API_KEY,

-# TV_JIRA_POLL_INTERVAL=2m). Precedence: env > this file > defaults.

+# 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.