I would like to add a "reputation score" that uses the risk assessment. A user should start with a neutral reputation score and submitting bad requests (without descriptions or without tickets) should lower their reputation by a certain amount, once they reach a certain reputation their teleport requests should always require manual approval until they have submitted enough good requests to bring their reputation score above that threshold. What are some ways we can acomplish this? What are some options for storing these metrics? Can we push these metrics into opentelemetry collector to put them in datadog?


AskUserQuestion CronCreate CronDelete CronList EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Monitor NotebookEdit PushNotification RemoteTrigger TaskCreate TaskGet TaskList TaskOutput TaskStop TaskUpdate WebFetch WebSearch


- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines.
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


default-src 'none'; frame-ancestors 'none'

_cfuvid=5Qz6fRtMdo2E3KS5ni7Lk8S5yY7GakEjZscPD2.Ctw8-1776954355.9282897-1.0.1.1-T5kej4GyKzExJJI1FZ5Rht7c.4NVEQcWhzvAT9Hj_c0; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com

Invalid authentication credentials


_cfuvid=7llpZCLnlZPvJ06xmd3lpZ7KK4BJxErikj0UGETOQ_E-1776954357.2680275-1.0.1.1-mAaEE2EWyjCYC.XuDHboy3cp9Yp2S6SNt5rFMbFjiFE; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com

Invalid authentication credentials


Please run /login · API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"},"request_id":"req_011CaLrghcQ7n3hjyha5484H"}


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


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


<local-command-stdout>Login successful</local-command-stdout>


Bash

ls /Users/steve.briggs/projects/operations/services/televibe/ && find /Users/steve.briggs/projects/operations/services/televibe -type f -name "*.go" | head -40

List televibe directory contents


drwxr-xr-x steve.briggs staff 160 B  Wed Apr 22 14:50:02 2026 ..
drwxr-xr-x steve.briggs staff 160 B  Wed Apr 22 15:11:25 2026 internal
.rw-r--r-- steve.briggs staff 306 B  Wed Apr 22 15:15:35 2026 Dockerfile
.rw-r--r-- steve.briggs staff 4.0 MB Wed Apr 22 15:35:03 2026 logo.png
.rw-r--r-- steve.briggs staff 2.4 KB Wed Apr 22 15:39:30 2026 main.go
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 22 15:39:50 2026 .claude
.rw-r--r-- steve.briggs staff  17 KB Wed Apr 22 15:39:51 2026 go.sum
.rw-r--r-- steve.briggs staff 3.6 KB Wed Apr 22 15:39:51 2026 go.mod
drwxr-xr-x steve.briggs staff 320 B  Wed Apr 22 15:57:57 2026 .
.rw------- steve.briggs staff  11 KB Wed Apr 22 15:57:57 2026 identity.file
/Users/steve.briggs/projects/operations/services/televibe/main.go
/Users/steve.briggs/projects/operations/services/televibe/internal/evaluator/evaluator_test.go
/Users/steve.briggs/projects/operations/services/televibe/internal/evaluator/evaluator.go
/Users/steve.briggs/projects/operations/services/televibe/internal/watcher/watcher.go
/Users/steve.briggs/projects/operations/services/televibe/internal/config/config.go
/Users/steve.briggs/projects/operations/services/televibe/internal/config/config_test.go


Agent

Explore televibe architecture for reputation feature

Prompt:
I'm designing a "reputation score" feature for the televibe service at /Users/steve.briggs/projects/operations/services/televibe/. Goal is to understand enough about the current design to discuss storage and metrics options with the user.

Specifically, please report back concisely (under 400 words) on:

1. What televibe does — one-paragraph summary of its purpose based on main.go, config.go, and the watcher/evaluator packages.
2. How the risk assessment / evaluator currently works — what inputs does it take (user identity? ticket? description?), what does it output (score? approve/reject? Teleport action?), and where does that output go.
3. The watcher — is it polling Teleport access requests? Listening to events?
4. Any existing persistence — does televibe currently read/write state anywhere (DB, file, in-memory only)? This is the critical question for storage design.
5. Any existing metrics/telemetry — does it already emit OpenTelemetry, Prometheus, structured logs, etc? List what's there.
6. How the user is identified in a request (email? UUID? Teleport username?).

Just exploration — no code changes. Report file paths with line numbers where relevant so I can jump to specifics later.


I would like to add a "reputation score" that uses the risk assessment. A user should start with a neutral reputation score and submitting bad requests (without descriptions or without tickets) should…


Excellent. Now I have a complete picture. Let me provide a concise summary:

---

## Televibe Architecture Summary

**Purpose (main.go, config.go, evaluator/):**
Televibe is a security risk evaluator for Teleport access requests. It streams pending access requests from Teleport, uses Claude (via Anthropic API or AWS Bedrock) to assign a risk score and risk level based on JIRA ticket presence and description quality, and logs the assessment. It runs as a long-lived daemon that continuously monitors and evaluates access requests in real time.

**Risk Assessment / Evaluator (evaluator.go):**
- **Inputs:** User identity (string), roles (string array), and request reason text (searched for JIRA ticket patterns)
- **Output:** `Assessment` struct containing risk_score (1–10 integer), risk_level ("low"/"medium"/"high"), description_quality, JIRA ticket details, and reasoning. Rules: 1–3 (low) requires both ticket AND clear description; 4–6 (medium) has one but not both; 7–10 (high) lacks both.
- **Destination:** Currently logged only via slog JSON to stdout (main.go:76–86). No persistence, no Teleport action sent back, no external storage.

**Watcher (watcher.go):**
**Event-driven streaming (not polling).** Creates a Teleport watcher (watcher.go:74–80) that listens for `OpPut` events and filters to only `PENDING` state access requests (line 106). Reconnects automatically on failure with 5-second backoff (line 14). Routes requests through buffered channel (capacity 32, line 37).

**Persistence:**
**None.** No database, cache, file storage, or in-memory state. Each evaluation is evaluated independently and logged; there is no record kept. The assessment result is not stored or acted upon—it's only logged and discarded.

**Telemetry/Metrics:**
**Minimal.** Uses only structured JSON logging via slog (main.go:20). Go.mod includes OpenTelemetry dependencies (line 52–60) but they are **not imported or used in the code**. No Prometheus metrics, no custom instrumentation, no event exports.

**User Identification:**
**Teleport username** (extracted as `User` from the Teleport AccessRequest; see watcher.go:125). Not email, not UUID—the Teleport credential identity.

---

**Key for reputation score design:** The current system evaluates requests ephemerally and discards results. To add reputation scoring, you'd need to (1) persist assessments (database/cache with user + timestamp), (2) aggregate historical scores, (3) decide where to store reputation state (new table? Redis? in-memory with durability?), and (4) optionally feed aggregated reputation back into Claude's context to influence future evaluations.

Prompt:
I'm designing a "reputation score" feature for the televibe service at /Users/steve.briggs/projects/operations/services/televibe/. Goal is to understand enough about the current design to discuss storage and metrics options with the user.

Specifically, please report back concisely (under 400 words) on:

1. What televibe does — one-paragraph summary of its purpose based on main.go, config.go, and the watcher/evaluator packages.
2. How the risk assessment / evaluator currently works — what inputs does it take (user identity? ticket? description?), what does it output (score? approve/reject? Teleport action?), and where does that output go.
3. The watcher — is it polling Teleport access requests? Listening to events?
4. Any existing persistence — does televibe currently read/write state anywhere (DB, file, in-memory only)? This is the critical question for storage design.
5. Any existing metrics/telemetry — does it already emit OpenTelemetry, Prometheus, structured logs, etc? List what's there.
6. How the user is identified in a request (email? UUID? Teleport username?).

Just exploration — no code changes. Report file paths with line numbers where relevant so I can jump to specifics later.


Sir Charles of St Cheese, here's the lay of the land and some options. Televibe today is purely ephemeral — it streams pending access requests, asks Claude for a 1–10 risk score, logs the JSON, and moves on. No state, no Teleport write-back, no metrics. So reputation is a net-new persistence concern on top of a service that currently has none.

## How to accomplish it

The evaluator already gives you the signal you need: `risk_score` (1–10) and a boolean-ish view of "has ticket" + "description quality". A straightforward shape:

1. After each evaluation in `internal/evaluator/evaluator.go`, derive a delta from the assessment (e.g. `good`: score ≤ 3 → +1; `bad`: score ≥ 7 → −2; medium → 0 or small decay).
2. Apply the delta to a per-user running score in a store, clamped to a range (say −20…+20, starting at 0).
3. In the watcher/main loop, before (or instead of) just logging, look up the user's current reputation. If below the threshold, take an action on the Teleport request — today that means calling Teleport's `SetAccessRequestState` to force it to stay pending / require manual approval, or annotating it with a note so the approver sees "low reputation: manual review required".
4. A nightly or per-request decay (exponential or linear) so one bad week doesn't brand someone forever.

One design choice worth flagging: today televibe only observes; it doesn't act on Teleport. Gating on reputation means televibe now becomes an approver/denier, which is a meaningful expansion of its blast radius. Might be worth splitting "compute reputation" (safe, observational) from "enforce reputation" (gating) as separate phases/flags.

## Storage options

Listed roughly in order of cheapest to most operationally serious:

- **SQLite on a persistent volume** — one file, zero infra, perfect for a single-replica daemon. Trivial to back up. Downside: you're pinned to one pod, harder to scale horizontally.
- **BoltDB / bbolt** — pure-Go embedded KV, no CGo like SQLite. Very much in your "standard-library-ish" comfort zone. Same single-replica caveat.
- **Postgres / RDS** — if televibe will ever run more than one replica, or you want other tools (dashboards, ad-hoc SQL) to read the reputation table. Immuta already runs Postgres everywhere. Heaviest lift.
- **DynamoDB** — single-digit-ms reads, per-user PK, TTL built in for decay. Good fit if this runs in AWS. Costs scale with request volume, which here is tiny.
- **Redis / ElastiCache** — fast, but you'd want AOF/RDB persistence since this is authoritative data, not a cache. Natural fit for INCRBY-style score updates.
- **Teleport user labels / traits** — arguably the most "native" option: store the reputation as a trait on the Teleport user. No new infra, the score travels with the identity, and manual reviewers can see it. Downside: writes against Teleport's API, rate limits, and you're bending Teleport's data model a bit.

My recommendation: **SQLite (or bbolt) to start**, with a thin repository interface so you can swap to Postgres/Dynamo later without rewriting the evaluator. Matches the current "single daemon" footprint and keeps the operational surface near zero.

## OTel → Datadog — yes, but not as the source of truth

Short answer: yes, televibe can absolutely export metrics to an OTel collector and have Datadog pick them up. The OTel deps are already in `go.mod` but unused — wiring up an OTLP exporter pointed at your collector is ~30 lines. Natural metrics to emit:

- `televibe.assessment.risk_score` (histogram, tagged by `user`, `risk_level`, `has_ticket`)
- `televibe.reputation.score` (gauge, tagged by `user`)
- `televibe.reputation.delta` (counter, tagged by `reason`)
- `televibe.request.gated` (counter, tagged by `user`, `action`)

That gives you great dashboards and alerting ("alert when any user's reputation drops below −10", "top 10 worst offenders this week").

**But** — don't use Datadog/OTel as the *authoritative* store for the reputation score. Metrics pipelines are lossy, sampled, eventually consistent, and high-cardinality per-user gauges get expensive fast in Datadog. Reading a metric back at request-evaluation time to decide "does this request need manual approval?" is an anti-pattern: you'd be making an authz decision off a system designed for observability. Keep the source of truth in a real store, and **mirror** the derived state to OTel for humans.

One more cardinality wrinkle: tagging every metric with `user` will blow up Datadog custom-metric costs if you have many distinct users. You may want to emit the per-user gauge only for users currently below the threshold, and keep aggregate metrics (histogram of all scores, counter of gated requests) untagged.

Want me to sketch the schema + interface for the storage layer, or prototype the OTel wiring first?


lets use teleport user lavels/traits and output with otel, lets keep the decisions manual for now for testing purposes


Good call on manual-only for now, Sir Charles — computing and publishing reputation without acting on it gives you a full debugging cycle before televibe ever touches an approval decision.

Here's what that shape looks like concretely. Before I start writing code, I want to nail down a few defaults so we're aligned:

## Proposed shape

**Trait name & format.** Teleport traits are `map[string][]string`, so I'd suggest:
- `reputation_score` → `["0"]` (single-element slice holding the current int)
- `reputation_updated_at` → `["2026-04-23T14:00:00Z"]`
- `reputation_last_delta` → `["-2"]` (for approver visibility)

Traits show up in the Teleport UI on the user object, and approvers can see them when reviewing the request.

**Delta function (first pass):**
| Risk level | Has ticket | Clear description | Delta |
|---|---|---|---|
| low (1–3) | yes | yes | +1 |
| medium (4–6) | one of | one of | 0 |
| high (7–10) | no | no | −2 |

Clamped to `[-20, +20]`, starts at `0` on first sighting.

**Decay:** skip for v1 — easier to reason about during testing. Add it once we see real data.

**Flow inside the service:**
1. `evaluator` returns the assessment (unchanged).
2. New `reputation` package: reads current trait via Teleport client → computes delta → writes new trait value back. Idempotent on Teleport username.
3. OTel meter emits: `televibe.assessment.risk_score` (histogram), `televibe.reputation.score` (gauge per user), `televibe.reputation.delta` (counter, tagged by reason).
4. **No** call to `SetAccessRequestState` — we log the assessment + reputation, approver decides manually.

## Things I need you to confirm / decide

1. **Teleport permissions.** Does the identity in `identity.file` currently have `user.update` / `user.read`? Writing traits needs it and watching access requests doesn't, so this is the likely snag. If not, we'll need to extend the role it's bound to.
2. **OTLP endpoint.** Where's the OTel collector televibe should ship to — env var `OTEL_EXPORTER_OTLP_ENDPOINT`, a fixed hostname in config, or something running as a sidecar?
3. **Metric cardinality.** OK to tag the per-user `reputation.score` gauge with `user` for now (tiny population, testing only), and we revisit before prod?
4. **Delta table above** — happy with those numbers, or want them tunable via config from day one?

Once those four are answered I'll plan the code changes (probably `internal/reputation/`, OTel bootstrap in `main.go`, wiring through the watcher loop) and implement.


1. yes, 2. the OTLP endpoint configuration should use the env var, typically on localhost:4317 but if no env var is set just disable OTLP shipping. 3. yes that is fine. 4. the deltas look good but lets make them tunable via configuration


Great. Let me read the current code to ground the implementation, then plan and build.


1	package main
2
3	import (
4		"context"
5		"log/slog"
6		"os"
7		"os/signal"
8		"syscall"
9
10		"github.com/anthropics/anthropic-sdk-go"
11		"github.com/anthropics/anthropic-sdk-go/bedrock"
12		"github.com/anthropics/anthropic-sdk-go/option"
13		awsconfig "github.com/aws/aws-sdk-go-v2/config"
14		"github.com/immuta/operations/services/televibe/internal/config"
15		"github.com/immuta/operations/services/televibe/internal/evaluator"
16		"github.com/immuta/operations/services/televibe/internal/watcher"
17	)
18
19	func main() {
20		logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
21
22		cfg, err := config.Load()
23		if err != nil {
24			logger.Error("configuration error", "error", err)
25			os.Exit(1)
26		}
27
28		var anthropicClient anthropic.Client
29		switch cfg.LLMProvider {
30		case config.ProviderBedrock:
31			opts := []func(*awsconfig.LoadOptions) error{}
32			if cfg.BedrockRegion != "" {
33				opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
34			}
35			anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
36			logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
37		default:
38			anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
39			logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
40		}
41
42		eval := evaluator.New(anthropicClient, cfg.ClaudeModel)
43
44		ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
45		defer cancel()
46
47		w, err := watcher.New(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile, logger)
48		if err != nil {
49			logger.Error("failed to connect to teleport", "error", err)
50			os.Exit(1)
51		}
52		defer w.Close()
53
54		logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)
55
56		for {
57			select {
58			case req, ok := <-w.Requests():
59				if !ok {
60					return
61				}
62				go evaluate(ctx, eval, req, logger)
63			case <-ctx.Done():
64				return
65			}
66		}
67	}
68
69	func evaluate(ctx context.Context, eval *evaluator.Evaluator, req evaluator.Request, logger *slog.Logger) {
70		a, err := eval.Evaluate(ctx, req)
71		if err != nil {
72			logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
73			return
74		}
75
76		logger.Info("access request evaluated",
77			"request_id", req.ID,
78			"user", req.User,
79			"roles", req.Roles,
80			"risk_score", a.RiskScore,
81			"risk_level", a.RiskLevel,
82			"has_jira_ticket", a.HasJIRATicket,
83			"jira_ticket", a.JIRATicket,
84			"description_quality", a.DescriptionQuality,
85			"reasoning", a.Reasoning,
86		)
87	}
88

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/watcher"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

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

	w, err := watcher.New(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile, logger)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer w.Close()

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(ctx context.Context, eval *evaluator.Evaluator, req evaluator.Request, logger *slog.Logger) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	logger.Info("access request evaluated",
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	)
}


1	package config
2
3	import (
4		"errors"
5		"os"
6	)
7
8	const (
9		ProviderAnthropic = "anthropic"
10		ProviderBedrock   = "bedrock"
11
12		defaultModelAnthropic = "claude-sonnet-4-6"
13		// Bedrock model IDs use cross-region inference profile format.
14		// Override with CLAUDE_MODEL if you need a different version.
15		defaultModelBedrock = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
16	)
17
18	type Config struct {
19		TeleportAddr         string
20		TeleportIdentityFile string
21		LLMProvider          string // "anthropic" (default) or "bedrock"
22		AnthropicAPIKey      string // required when LLMProvider == "anthropic"
23		BedrockRegion        string // optional; falls back to AWS_DEFAULT_REGION / profile
24		ClaudeModel          string
25	}
26
27	func Load() (*Config, error) {
28		cfg := &Config{
29			TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
30			TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
31			LLMProvider:          os.Getenv("LLM_PROVIDER"),
32			AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
33			BedrockRegion:        os.Getenv("BEDROCK_REGION"),
34			ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
35		}
36
37		if cfg.LLMProvider == "" {
38			cfg.LLMProvider = ProviderAnthropic
39		}
40
41		if cfg.ClaudeModel == "" {
42			switch cfg.LLMProvider {
43			case ProviderBedrock:
44				cfg.ClaudeModel = defaultModelBedrock
45			default:
46				cfg.ClaudeModel = defaultModelAnthropic
47			}
48		}
49
50		if cfg.TeleportAddr == "" {
51			return nil, errors.New("TELEPORT_ADDR is required")
52		}
53		if cfg.TeleportIdentityFile == "" {
54			return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
55		}
56
57		switch cfg.LLMProvider {
58		case ProviderAnthropic:
59			if cfg.AnthropicAPIKey == "" {
60				return nil, errors.New("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")
61			}
62		case ProviderBedrock:
63			// credentials come from the standard AWS credential chain
64		default:
65			return nil, errors.New("LLM_PROVIDER must be 'anthropic' or 'bedrock'")
66		}
67
68		return cfg, nil
69	}
70

package config

import (
	"errors"
	"os"
)

const (
	ProviderAnthropic = "anthropic"
	ProviderBedrock   = "bedrock"

	defaultModelAnthropic = "claude-sonnet-4-6"
	// Bedrock model IDs use cross-region inference profile format.
	// Override with CLAUDE_MODEL if you need a different version.
	defaultModelBedrock = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
)

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	LLMProvider          string // "anthropic" (default) or "bedrock"
	AnthropicAPIKey      string // required when LLMProvider == "anthropic"
	BedrockRegion        string // optional; falls back to AWS_DEFAULT_REGION / profile
	ClaudeModel          string
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		LLMProvider:          os.Getenv("LLM_PROVIDER"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		BedrockRegion:        os.Getenv("BEDROCK_REGION"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
	}

	if cfg.LLMProvider == "" {
		cfg.LLMProvider = ProviderAnthropic
	}

	if cfg.ClaudeModel == "" {
		switch cfg.LLMProvider {
		case ProviderBedrock:
			cfg.ClaudeModel = defaultModelBedrock
		default:
			cfg.ClaudeModel = defaultModelAnthropic
		}
	}

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}

	switch cfg.LLMProvider {
	case ProviderAnthropic:
		if cfg.AnthropicAPIKey == "" {
			return nil, errors.New("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")
		}
	case ProviderBedrock:
		// credentials come from the standard AWS credential chain
	default:
		return nil, errors.New("LLM_PROVIDER must be 'anthropic' or 'bedrock'")
	}

	return cfg, nil
}


1	package evaluator
2
3	import (
4		"context"
5		"encoding/json"
6		"fmt"
7		"regexp"
8		"strings"
9
10		"github.com/anthropics/anthropic-sdk-go"
11	)
12
13	// jiraPattern matches CUSTX-NNNN and ITSM-NNNN ticket references (case-insensitive).
14	var jiraPattern = regexp.MustCompile(`(?i)\b(CUSTX|ITSM)-\d+\b`)
15
16	// RiskLevel represents the severity of an access request.
17	type RiskLevel string
18
19	const (
20		RiskLow    RiskLevel = "low"
21		RiskMedium RiskLevel = "medium"
22		RiskHigh   RiskLevel = "high"
23	)
24
25	// Assessment is the structured output of a Claude risk evaluation.
26	type Assessment struct {
27		JIRATicket         string    `json:"jira_ticket"`
28		HasJIRATicket      bool      `json:"has_jira_ticket"`
29		DescriptionQuality string    `json:"description_quality"`
30		RiskScore          int       `json:"risk_score"`
31		RiskLevel          RiskLevel `json:"risk_level"`
32		Reasoning          string    `json:"reasoning"`
33	}
34
35	// Request holds the access request fields that the evaluator needs.
36	type Request struct {
37		ID     string
38		User   string
39		Roles  []string
40		Reason string
41	}
42
43	// Evaluator calls Claude to assign a risk score to a Teleport access request.
44	type Evaluator struct {
45		client anthropic.Client
46		model  string
47	}
48
49	func New(client anthropic.Client, model string) *Evaluator {
50		return &Evaluator{client: client, model: model}
51	}
52
53	func (e *Evaluator) Evaluate(ctx context.Context, req Request) (*Assessment, error) {
54		tickets := jiraPattern.FindAllString(req.Reason, -1)
55		prompt := buildPrompt(req.User, req.Roles, req.Reason, tickets)
56
57		msg, err := e.client.Messages.New(ctx, anthropic.MessageNewParams{
58			Model:     e.model,
59			MaxTokens: 512,
60			System: []anthropic.TextBlockParam{
61				{Text: "You are a security risk evaluator for Teleport access requests. Respond only with valid JSON."},
62			},
63			Messages: []anthropic.MessageParam{
64				anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
65			},
66		})
67		if err != nil {
68			return nil, fmt.Errorf("claude API: %w", err)
69		}
70
71		if len(msg.Content) == 0 {
72			return nil, fmt.Errorf("empty response from claude")
73		}
74
75		text := strings.TrimSpace(msg.Content[0].Text)
76		// Strip markdown code fences that Claude sometimes adds.
77		if strings.HasPrefix(text, "```") {
78			if i := strings.Index(text[3:], "\n"); i >= 0 {
79				text = strings.TrimSuffix(strings.TrimSpace(text[3+i+1:]), "```")
80			}
81		}
82
83		var a Assessment
84		if err := json.Unmarshal([]byte(text), &a); err != nil {
85			return nil, fmt.Errorf("parsing response: %w (raw: %s)", err, text)
86		}
87
88		return &a, nil
89	}
90
91	func buildPrompt(user string, roles []string, reason string, tickets []string) string {
92		ticketInfo := "none"
93		if len(tickets) > 0 {
94			ticketInfo = strings.Join(tickets, ", ")
95		}
96
97		return fmt.Sprintf(`You are a security risk evaluator for Teleport access requests. Respond ONLY with a valid JSON object — no explanation, no markdown.
98
99	Access request details:
100	  User:            %s
101	  Requested roles: %s
102	  Reason provided: %q
103	  JIRA tickets:    %s
104
105	Risk scoring rules:
106	  1–3  (low)    — valid JIRA ticket (CUSTX-NNN or ITSM-NNN) AND a clear description with business justification
107	  4–6  (medium) — either a valid JIRA ticket OR a clear description, but not both
108	  7–10 (high)   — no JIRA ticket AND no/vague description (single words or generic phrases like "need access" score 9–10)
109
110	Return exactly this JSON shape:
111	{
112	  "jira_ticket":          "<ticket ID or null>",
113	  "has_jira_ticket":      <true|false>,
114	  "description_quality":  "<none|poor|fair|good|excellent>",
115	  "risk_score":           <integer 1–10>,
116	  "risk_level":           "<low|medium|high>",
117	  "reasoning":            "<one concise sentence>"
118	}`,
119			user,
120			strings.Join(roles, ", "),
121			reason,
122			ticketInfo,
123		)
124	}
125

package evaluator

import (
	"context"
	"encoding/json"
	"fmt"
	"regexp"
	"strings"

	"github.com/anthropics/anthropic-sdk-go"
)

// jiraPattern matches CUSTX-NNNN and ITSM-NNNN ticket references (case-insensitive).
var jiraPattern = regexp.MustCompile(`(?i)\b(CUSTX|ITSM)-\d+\b`)

// RiskLevel represents the severity of an access request.
type RiskLevel string

const (
	RiskLow    RiskLevel = "low"
	RiskMedium RiskLevel = "medium"
	RiskHigh   RiskLevel = "high"
)

// Assessment is the structured output of a Claude risk evaluation.
type Assessment struct {
	JIRATicket         string    `json:"jira_ticket"`
	HasJIRATicket      bool      `json:"has_jira_ticket"`
	DescriptionQuality string    `json:"description_quality"`
	RiskScore          int       `json:"risk_score"`
	RiskLevel          RiskLevel `json:"risk_level"`
	Reasoning          string    `json:"reasoning"`
}

// Request holds the access request fields that the evaluator needs.
type Request struct {
	ID     string
	User   string
	Roles  []string
	Reason string
}

// Evaluator calls Claude to assign a risk score to a Teleport access request.
type Evaluator struct {
	client anthropic.Client
	model  string
}

func New(client anthropic.Client, model string) *Evaluator {
	return &Evaluator{client: client, model: model}
}

func (e *Evaluator) Evaluate(ctx context.Context, req Request) (*Assessment, error) {
	tickets := jiraPattern.FindAllString(req.Reason, -1)
	prompt := buildPrompt(req.User, req.Roles, req.Reason, tickets)

	msg, err := e.client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     e.model,
		MaxTokens: 512,
		System: []anthropic.TextBlockParam{
			{Text: "You are a security risk evaluator for Teleport access requests. Respond only with valid JSON."},
		},
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("claude API: %w", err)
	}

	if len(msg.Content) == 0 {
		return nil, fmt.Errorf("empty response from claude")
	}

	text := strings.TrimSpace(msg.Content[0].Text)
	// Strip markdown code fences that Claude sometimes adds.
	if strings.HasPrefix(text, "```") {
		if i := strings.Index(text[3:], "\n"); i >= 0 {
			text = strings.TrimSuffix(strings.TrimSpace(text[3+i+1:]), "```")
		}
	}

	var a Assessment
	if err := json.Unmarshal([]byte(text), &a); err != nil {
		return nil, fmt.Errorf("parsing response: %w (raw: %s)", err, text)
	}

	return &a, nil
}

func buildPrompt(user string, roles []string, reason string, tickets []string) string {
	ticketInfo := "none"
	if len(tickets) > 0 {
		ticketInfo = strings.Join(tickets, ", ")
	}

	return fmt.Sprintf(`You are a security risk evaluator for Teleport access requests. Respond ONLY with a valid JSON object — no explanation, no markdown.

Access request details:
  User:            %s
  Requested roles: %s
  Reason provided: %q
  JIRA tickets:    %s

Risk scoring rules:
  1–3  (low)    — valid JIRA ticket (CUSTX-NNN or ITSM-NNN) AND a clear description with business justification
  4–6  (medium) — either a valid JIRA ticket OR a clear description, but not both
  7–10 (high)   — no JIRA ticket AND no/vague description (single words or generic phrases like "need access" score 9–10)

Return exactly this JSON shape:
{
  "jira_ticket":          "<ticket ID or null>",
  "has_jira_ticket":      <true|false>,
  "description_quality":  "<none|poor|fair|good|excellent>",
  "risk_score":           <integer 1–10>,
  "risk_level":           "<low|medium|high>",
  "reasoning":            "<one concise sentence>"
}`,
		user,
		strings.Join(roles, ", "),
		reason,
		ticketInfo,
	)
}


1	package watcher
2
3	import (
4		"context"
5		"fmt"
6		"log/slog"
7		"time"
8
9		"github.com/gravitational/teleport/api/client"
10		"github.com/gravitational/teleport/api/types"
11		"github.com/immuta/operations/services/televibe/internal/evaluator"
12	)
13
14	const reconnectDelay = 5 * time.Second
15
16	// Watcher streams pending Teleport access requests onto a channel.
17	// It reconnects automatically on watcher failure.
18	type Watcher struct {
19		client   *client.Client
20		requests chan evaluator.Request
21		logger   *slog.Logger
22	}
23
24	func New(ctx context.Context, addr, identityFile string, logger *slog.Logger) (*Watcher, error) {
25		clt, err := client.New(ctx, client.Config{
26			Addrs: []string{addr},
27			Credentials: []client.Credentials{
28				client.LoadIdentityFile(identityFile),
29			},
30		})
31		if err != nil {
32			return nil, fmt.Errorf("connecting to teleport: %w", err)
33		}
34
35		w := &Watcher{
36			client:   clt,
37			requests: make(chan evaluator.Request, 32),
38			logger:   logger,
39		}
40
41		go w.watch(ctx)
42		return w, nil
43	}
44
45	// Requests returns the channel of incoming pending access requests.
46	func (w *Watcher) Requests() <-chan evaluator.Request {
47		return w.requests
48	}
49
50	// Close shuts down the Teleport client.
51	func (w *Watcher) Close() {
52		w.client.Close()
53	}
54
55	func (w *Watcher) watch(ctx context.Context) {
56		defer close(w.requests)
57
58		for {
59			if err := w.watchOnce(ctx); err != nil {
60				if ctx.Err() != nil {
61					return
62				}
63				w.logger.Error("watcher disconnected, reconnecting", "error", err, "delay", reconnectDelay)
64				select {
65				case <-time.After(reconnectDelay):
66				case <-ctx.Done():
67					return
68				}
69			}
70		}
71	}
72
73	func (w *Watcher) watchOnce(ctx context.Context) error {
74		watcher, err := w.client.NewWatcher(ctx, types.Watch{
75			Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
76		})
77		if err != nil {
78			return fmt.Errorf("creating watcher: %w", err)
79		}
80		defer watcher.Close()
81
82		// The first event must be OpInit confirming the watch is established.
83		select {
84		case event := <-watcher.Events():
85			if event.Type != types.OpInit {
86				return fmt.Errorf("expected OpInit, got %v", event.Type)
87			}
88			w.logger.Info("teleport watcher initialized")
89		case <-watcher.Done():
90			return fmt.Errorf("watcher closed during init: %w", watcher.Error())
91		case <-ctx.Done():
92			return ctx.Err()
93		}
94
95		for {
96			select {
97			case event := <-watcher.Events():
98				if event.Type != types.OpPut {
99					continue
100				}
101				req, ok := event.Resource.(types.AccessRequest)
102				if !ok {
103					continue
104				}
105				// Only forward newly-pending requests; ignore state transitions (approve/deny).
106				if req.GetState() != types.RequestState_PENDING {
107					continue
108				}
109				select {
110				case w.requests <- toRequest(req):
111				case <-ctx.Done():
112					return ctx.Err()
113				}
114			case <-watcher.Done():
115				return fmt.Errorf("watcher closed: %w", watcher.Error())
116			case <-ctx.Done():
117				return ctx.Err()
118			}
119		}
120	}
121
122	func toRequest(req types.AccessRequest) evaluator.Request {
123		return evaluator.Request{
124			ID:     req.GetName(),
125			User:   req.GetUser(),
126			Roles:  req.GetRoles(),
127			Reason: req.GetRequestReason(),
128		}
129	}
130

package watcher

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

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
)

const reconnectDelay = 5 * time.Second

// Watcher streams pending Teleport access requests onto a channel.
// It reconnects automatically on watcher failure.
type Watcher struct {
	client   *client.Client
	requests chan evaluator.Request
	logger   *slog.Logger
}

func New(ctx context.Context, addr, identityFile string, logger *slog.Logger) (*Watcher, error) {
	clt, err := client.New(ctx, client.Config{
		Addrs: []string{addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(identityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("connecting to teleport: %w", err)
	}

	w := &Watcher{
		client:   clt,
		requests: make(chan evaluator.Request, 32),
		logger:   logger,
	}

	go w.watch(ctx)
	return w, nil
}

// Requests returns the channel of incoming pending access requests.
func (w *Watcher) Requests() <-chan evaluator.Request {
	return w.requests
}

// Close shuts down the Teleport client.
func (w *Watcher) Close() {
	w.client.Close()
}

func (w *Watcher) watch(ctx context.Context) {
	defer close(w.requests)

	for {
		if err := w.watchOnce(ctx); err != nil {
			if ctx.Err() != nil {
				return
			}
			w.logger.Error("watcher disconnected, reconnecting", "error", err, "delay", reconnectDelay)
			select {
			case <-time.After(reconnectDelay):
			case <-ctx.Done():
				return
			}
		}
	}
}

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

	// The first event must be OpInit confirming the watch is established.
	select {
	case event := <-watcher.Events():
		if event.Type != types.OpInit {
			return fmt.Errorf("expected OpInit, got %v", event.Type)
		}
		w.logger.Info("teleport watcher initialized")
	case <-watcher.Done():
		return fmt.Errorf("watcher closed during init: %w", watcher.Error())
	case <-ctx.Done():
		return ctx.Err()
	}

	for {
		select {
		case event := <-watcher.Events():
			if event.Type != types.OpPut {
				continue
			}
			req, ok := event.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			// Only forward newly-pending requests; ignore state transitions (approve/deny).
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			select {
			case w.requests <- toRequest(req):
			case <-ctx.Done():
				return ctx.Err()
			}
		case <-watcher.Done():
			return fmt.Errorf("watcher closed: %w", watcher.Error())
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func toRequest(req types.AccessRequest) evaluator.Request {
	return evaluator.Request{
		ID:     req.GetName(),
		User:   req.GetUser(),
		Roles:  req.GetRoles(),
		Reason: req.GetRequestReason(),
	}
}


1	module github.com/immuta/operations/services/televibe
2
3	go 1.25.9
4
5	require (
6		github.com/anthropics/anthropic-sdk-go v1.37.0
7		github.com/aws/aws-sdk-go-v2/config v1.27.27
8		github.com/gravitational/teleport/api v0.0.0-20260422182531-ef25e8e6ba4d
9		github.com/stretchr/testify v1.11.1
10	)
11
12	require (
13		github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
14		github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
15		github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
16		github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
17		github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
18		github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
19		github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
20		github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
21		github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
22		github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
23		github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
24		github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
25		github.com/aws/smithy-go v1.20.3 // indirect
26		github.com/beevik/etree v1.6.0 // indirect
27		github.com/cenkalti/backoff/v5 v5.0.3 // indirect
28		github.com/cespare/xxhash/v2 v2.3.0 // indirect
29		github.com/charlievieth/strcase v0.0.5 // indirect
30		github.com/coreos/go-semver v0.3.1 // indirect
31		github.com/davecgh/go-spew v1.1.1 // indirect
32		github.com/felixge/httpsnoop v1.0.4 // indirect
33		github.com/go-logr/logr v1.4.3 // indirect
34		github.com/go-logr/stdr v1.2.2 // indirect
35		github.com/go-piv/piv-go/v2 v2.5.0 // indirect
36		github.com/gobwas/httphead v0.1.0 // indirect
37		github.com/gobwas/pool v0.2.1 // indirect
38		github.com/gobwas/ws v1.4.0 // indirect
39		github.com/gogo/protobuf v1.3.2 // indirect
40		github.com/google/uuid v1.6.0 // indirect
41		github.com/gravitational/trace v1.5.3 // indirect
42		github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
43		github.com/jonboulle/clockwork v0.5.0 // indirect
44		github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
45		github.com/pmezard/go-difflib v1.0.0 // indirect
46		github.com/russellhaering/gosaml2 v0.11.0 // indirect
47		github.com/russellhaering/goxmldsig v1.6.0 // indirect
48		github.com/tidwall/gjson v1.18.0 // indirect
49		github.com/tidwall/match v1.1.1 // indirect
50		github.com/tidwall/pretty v1.2.1 // indirect
51		github.com/tidwall/sjson v1.2.5 // indirect
52		go.opentelemetry.io/auto/sdk v1.2.1 // indirect
53		go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
54		go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
55		go.opentelemetry.io/otel v1.43.0 // indirect
56		go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
57		go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
58		go.opentelemetry.io/otel/metric v1.43.0 // indirect
59		go.opentelemetry.io/otel/sdk v1.43.0 // indirect
60		go.opentelemetry.io/otel/trace v1.43.0 // indirect
61		go.opentelemetry.io/proto/otlp v1.10.0 // indirect
62		golang.org/x/crypto v0.49.0 // indirect
63		golang.org/x/net v0.52.0 // indirect
64		golang.org/x/sync v0.20.0 // indirect
65		golang.org/x/sys v0.42.0 // indirect
66		golang.org/x/term v0.41.0 // indirect
67		golang.org/x/text v0.35.0 // indirect
68		google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
69		google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
70		google.golang.org/grpc v1.80.0 // indirect
71		google.golang.org/protobuf v1.36.11 // indirect
72		gopkg.in/yaml.v2 v2.4.0 // indirect
73		gopkg.in/yaml.v3 v3.0.1 // indirect
74	)
75

module github.com/immuta/operations/services/televibe

go 1.25.9

require (
	github.com/anthropics/anthropic-sdk-go v1.37.0
	github.com/aws/aws-sdk-go-v2/config v1.27.27
	github.com/gravitational/teleport/api v0.0.0-20260422182531-ef25e8e6ba4d
	github.com/stretchr/testify v1.11.1
)

require (
	github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
	github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
	github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
	github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
	github.com/aws/smithy-go v1.20.3 // indirect
	github.com/beevik/etree v1.6.0 // indirect
	github.com/cenkalti/backoff/v5 v5.0.3 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/charlievieth/strcase v0.0.5 // indirect
	github.com/coreos/go-semver v0.3.1 // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/felixge/httpsnoop v1.0.4 // indirect
	github.com/go-logr/logr v1.4.3 // indirect
	github.com/go-logr/stdr v1.2.2 // indirect
	github.com/go-piv/piv-go/v2 v2.5.0 // indirect
	github.com/gobwas/httphead v0.1.0 // indirect
	github.com/gobwas/pool v0.2.1 // indirect
	github.com/gobwas/ws v1.4.0 // indirect
	github.com/gogo/protobuf v1.3.2 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/gravitational/trace v1.5.3 // indirect
	github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
	github.com/jonboulle/clockwork v0.5.0 // indirect
	github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/russellhaering/gosaml2 v0.11.0 // indirect
	github.com/russellhaering/goxmldsig v1.6.0 // indirect
	github.com/tidwall/gjson v1.18.0 // indirect
	github.com/tidwall/match v1.1.1 // indirect
	github.com/tidwall/pretty v1.2.1 // indirect
	github.com/tidwall/sjson v1.2.5 // indirect
	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
	go.opentelemetry.io/otel v1.43.0 // indirect
	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
	go.opentelemetry.io/otel/metric v1.43.0 // indirect
	go.opentelemetry.io/otel/sdk v1.43.0 // indirect
	go.opentelemetry.io/otel/trace v1.43.0 // indirect
	go.opentelemetry.io/proto/otlp v1.10.0 // indirect
	golang.org/x/crypto v0.49.0 // indirect
	golang.org/x/net v0.52.0 // indirect
	golang.org/x/sync v0.20.0 // indirect
	golang.org/x/sys v0.42.0 // indirect
	golang.org/x/term v0.41.0 // indirect
	golang.org/x/text v0.35.0 // indirect
	google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
	google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
	google.golang.org/grpc v1.80.0 // indirect
	google.golang.org/protobuf v1.36.11 // indirect
	gopkg.in/yaml.v2 v2.4.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)


1	package config
2
3	import (
4		"testing"
5
6		"github.com/stretchr/testify/assert"
7		"github.com/stretchr/testify/require"
8	)
9
10	func TestLoad(t *testing.T) {
11		allKeys := []string{
12			"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE",
13			"LLM_PROVIDER", "ANTHROPIC_API_KEY", "BEDROCK_REGION", "CLAUDE_MODEL",
14		}
15
16		base := map[string]string{
17			"TELEPORT_ADDR":          "tp.example.com:443",
18			"TELEPORT_IDENTITY_FILE": "/id.pem",
19			"ANTHROPIC_API_KEY":      "sk-ant-key",
20		}
21
22		tests := []struct {
23			name             string
24			env              map[string]string
25			wantErr          string
26			wantProvider     string
27			wantModel        string
28			wantBedrockRegion string
29		}{
30			{
31				name:    "missing TELEPORT_ADDR",
32				env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
33				wantErr: "TELEPORT_ADDR is required",
34			},
35			{
36				name:    "missing TELEPORT_IDENTITY_FILE",
37				env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
38				wantErr: "TELEPORT_IDENTITY_FILE is required",
39			},
40			{
41				name:    "missing ANTHROPIC_API_KEY with default provider",
42				env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
43				wantErr: "ANTHROPIC_API_KEY is required",
44			},
45			{
46				name:    "invalid LLM_PROVIDER",
47				env:     merge(base, map[string]string{"LLM_PROVIDER": "openai"}),
48				wantErr: "LLM_PROVIDER must be",
49			},
50			{
51				name:         "defaults (anthropic)",
52				env:          base,
53				wantProvider: ProviderAnthropic,
54				wantModel:    defaultModelAnthropic,
55			},
56			{
57				name:         "explicit anthropic provider",
58				env:          merge(base, map[string]string{"LLM_PROVIDER": "anthropic"}),
59				wantProvider: ProviderAnthropic,
60				wantModel:    defaultModelAnthropic,
61			},
62			{
63				name:         "bedrock provider, no API key required",
64				env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock"},
65				wantProvider: ProviderBedrock,
66				wantModel:    defaultModelBedrock,
67			},
68			{
69				name:              "bedrock provider with region",
70				env:               map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "BEDROCK_REGION": "eu-west-1"},
71				wantProvider:      ProviderBedrock,
72				wantModel:         defaultModelBedrock,
73				wantBedrockRegion: "eu-west-1",
74			},
75			{
76				name:         "custom model overrides provider default",
77				env:          merge(base, map[string]string{"CLAUDE_MODEL": "claude-opus-4-7"}),
78				wantProvider: ProviderAnthropic,
79				wantModel:    "claude-opus-4-7",
80			},
81			{
82				name:         "bedrock with custom model",
83				env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "CLAUDE_MODEL": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"},
84				wantProvider: ProviderBedrock,
85				wantModel:    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
86			},
87		}
88
89		for _, tt := range tests {
90			t.Run(tt.name, func(t *testing.T) {
91				for _, key := range allKeys {
92					t.Setenv(key, "")
93				}
94				for k, v := range tt.env {
95					t.Setenv(k, v)
96				}
97
98				cfg, err := Load()
99
100				if tt.wantErr != "" {
101					require.Error(t, err)
102					assert.Contains(t, err.Error(), tt.wantErr)
103					return
104				}
105
106				require.NoError(t, err)
107				assert.Equal(t, tt.wantProvider, cfg.LLMProvider)
108				assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
109				assert.Equal(t, tt.wantBedrockRegion, cfg.BedrockRegion)
110			})
111		}
112	}
113
114	func merge(base, overrides map[string]string) map[string]string {
115		out := make(map[string]string, len(base)+len(overrides))
116		for k, v := range base {
117			out[k] = v
118		}
119		for k, v := range overrides {
120			out[k] = v
121		}
122		return out
123	}
124

package config

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
	allKeys := []string{
		"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE",
		"LLM_PROVIDER", "ANTHROPIC_API_KEY", "BEDROCK_REGION", "CLAUDE_MODEL",
	}

	base := map[string]string{
		"TELEPORT_ADDR":          "tp.example.com:443",
		"TELEPORT_IDENTITY_FILE": "/id.pem",
		"ANTHROPIC_API_KEY":      "sk-ant-key",
	}

	tests := []struct {
		name             string
		env              map[string]string
		wantErr          string
		wantProvider     string
		wantModel        string
		wantBedrockRegion string
	}{
		{
			name:    "missing TELEPORT_ADDR",
			env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_ADDR is required",
		},
		{
			name:    "missing TELEPORT_IDENTITY_FILE",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_IDENTITY_FILE is required",
		},
		{
			name:    "missing ANTHROPIC_API_KEY with default provider",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
			wantErr: "ANTHROPIC_API_KEY is required",
		},
		{
			name:    "invalid LLM_PROVIDER",
			env:     merge(base, map[string]string{"LLM_PROVIDER": "openai"}),
			wantErr: "LLM_PROVIDER must be",
		},
		{
			name:         "defaults (anthropic)",
			env:          base,
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
		},
		{
			name:         "explicit anthropic provider",
			env:          merge(base, map[string]string{"LLM_PROVIDER": "anthropic"}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
		},
		{
			name:         "bedrock provider, no API key required",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock"},
			wantProvider: ProviderBedrock,
			wantModel:    defaultModelBedrock,
		},
		{
			name:              "bedrock provider with region",
			env:               map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "BEDROCK_REGION": "eu-west-1"},
			wantProvider:      ProviderBedrock,
			wantModel:         defaultModelBedrock,
			wantBedrockRegion: "eu-west-1",
		},
		{
			name:         "custom model overrides provider default",
			env:          merge(base, map[string]string{"CLAUDE_MODEL": "claude-opus-4-7"}),
			wantProvider: ProviderAnthropic,
			wantModel:    "claude-opus-4-7",
		},
		{
			name:         "bedrock with custom model",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "CLAUDE_MODEL": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"},
			wantProvider: ProviderBedrock,
			wantModel:    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			for _, key := range allKeys {
				t.Setenv(key, "")
			}
			for k, v := range tt.env {
				t.Setenv(k, v)
			}

			cfg, err := Load()

			if tt.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.wantProvider, cfg.LLMProvider)
			assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
			assert.Equal(t, tt.wantBedrockRegion, cfg.BedrockRegion)
		})
	}
}

func merge(base, overrides map[string]string) map[string]string {
	out := make(map[string]string, len(base)+len(overrides))
	for k, v := range base {
		out[k] = v
	}
	for k, v := range overrides {
		out[k] = v
	}
	return out
}


1	package evaluator
2
3	import (
4		"testing"
5
6		"github.com/stretchr/testify/assert"
7	)
8
9	func TestBuildPrompt_JiraTickets(t *testing.T) {
10		tests := []struct {
11			name        string
12			reason      string
13			wantTickets []string
14		}{
15			{
16				name:        "CUSTX ticket",
17				reason:      "Working on CUSTX-1234 customer migration",
18				wantTickets: []string{"CUSTX-1234"},
19			},
20			{
21				name:        "ITSM ticket",
22				reason:      "Resolving ITSM-9999 outage",
23				wantTickets: []string{"ITSM-9999"},
24			},
25			{
26				name:        "multiple tickets",
27				reason:      "CUSTX-100 and ITSM-200 both require this",
28				wantTickets: []string{"CUSTX-100", "ITSM-200"},
29			},
30			{
31				name:        "case insensitive",
32				reason:      "custx-555 access needed",
33				wantTickets: []string{"custx-555"},
34			},
35			{
36				name:        "no ticket",
37				reason:      "I need access to the database",
38				wantTickets: nil,
39			},
40			{
41				name:        "unrelated pattern",
42				reason:      "PROJ-123 is not a valid project",
43				wantTickets: nil,
44			},
45		}
46
47		for _, tt := range tests {
48			t.Run(tt.name, func(t *testing.T) {
49				got := jiraPattern.FindAllString(tt.reason, -1)
50				assert.Equal(t, tt.wantTickets, got)
51			})
52		}
53	}
54
55	func TestBuildPrompt_ContainsRequiredFields(t *testing.T) {
56		prompt := buildPrompt("alice", []string{"prod-db-readonly", "prod-k8s-viewer"}, "ITSM-42 emergency access needed", []string{"ITSM-42"})
57
58		assert.Contains(t, prompt, "alice")
59		assert.Contains(t, prompt, "prod-db-readonly")
60		assert.Contains(t, prompt, "prod-k8s-viewer")
61		assert.Contains(t, prompt, "ITSM-42")
62		assert.Contains(t, prompt, "risk_score")
63		assert.Contains(t, prompt, "reasoning")
64	}
65

package evaluator

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestBuildPrompt_JiraTickets(t *testing.T) {
	tests := []struct {
		name        string
		reason      string
		wantTickets []string
	}{
		{
			name:        "CUSTX ticket",
			reason:      "Working on CUSTX-1234 customer migration",
			wantTickets: []string{"CUSTX-1234"},
		},
		{
			name:        "ITSM ticket",
			reason:      "Resolving ITSM-9999 outage",
			wantTickets: []string{"ITSM-9999"},
		},
		{
			name:        "multiple tickets",
			reason:      "CUSTX-100 and ITSM-200 both require this",
			wantTickets: []string{"CUSTX-100", "ITSM-200"},
		},
		{
			name:        "case insensitive",
			reason:      "custx-555 access needed",
			wantTickets: []string{"custx-555"},
		},
		{
			name:        "no ticket",
			reason:      "I need access to the database",
			wantTickets: nil,
		},
		{
			name:        "unrelated pattern",
			reason:      "PROJ-123 is not a valid project",
			wantTickets: nil,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := jiraPattern.FindAllString(tt.reason, -1)
			assert.Equal(t, tt.wantTickets, got)
		})
	}
}

func TestBuildPrompt_ContainsRequiredFields(t *testing.T) {
	prompt := buildPrompt("alice", []string{"prod-db-readonly", "prod-k8s-viewer"}, "ITSM-42 emergency access needed", []string{"ITSM-42"})

	assert.Contains(t, prompt, "alice")
	assert.Contains(t, prompt, "prod-db-readonly")
	assert.Contains(t, prompt, "prod-k8s-viewer")
	assert.Contains(t, prompt, "ITSM-42")
	assert.Contains(t, prompt, "risk_score")
	assert.Contains(t, prompt, "reasoning")
}


1. yes, 2. the OTLP endpoint configuration should use the env var, typically on localhost:4317 but if no env var is set just disable OTLP shipping. 3. yes that is fine. 4. the deltas look good but let…


Let me load task tracking and the schedule tools since this is multi-step work.


ToolSearch

Question: select:TaskCreate,TaskUpdate,TaskList


Question: select:TaskCreate,TaskUpdate,TaskList


TaskCreate

Add reputation & OTLP config fields

Extend internal/config with OTLP endpoint, delta values, clamp range, starting score. Update config tests.


Task #1 created successfully: Add reputation & OTLP config fields

Add reputation & OTLP config fields


Create internal/telemetry package

OTel meter bootstrap with OTLP gRPC exporter. No-op provider when OTEL_EXPORTER_OTLP_ENDPOINT unset. Expose histogram/gauge/counter instruments used by the evaluate path.


Task #2 created successfully: Create internal/telemetry package

Create internal/telemetry package


Create internal/reputation package

Pure delta computation from assessment + config, and a Teleport-backed store that reads/writes user traits. Small interface for testability. Per-user mutex to avoid clobber.


Task #3 created successfully: Create internal/reputation package

Create internal/reputation package


Wire into main.go + add otlpmetricgrpc dep

Initialize telemetry + reputation in main, thread through evaluate goroutine. Add go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc to go.mod.


Task #4 created successfully: Wire into main.go + add otlpmetricgrpc dep


Run go build and go test

go build ./... and go test -race ./... from the televibe directory. Fix anything that breaks.


Task #5 created successfully: Run go build and go test

Run go build and go test


Updated task #1 status


Bash

go doc github.com/gravitational/teleport/api/client.Client.GetUser 2>&1 | head -30 && echo "---" && go doc github.com/gravitational/teleport/api/client.Client.UpdateUser 2>&1 | head -30 && echo "---" && go doc github.com/gravitational/teleport/api/types.User.GetTraits 2>&1 | head -20 && echo "---" && go doc github.com/gravitational/teleport/api/types.User.SetTraits 2>&1 | head -20

Check Teleport user API signatures


mise go@1.26.2            [1/3] install
mise go@1.26.2            [1/3] download go1.26.2.darwin-arm64.tar.gz
mise go@1.26.2            [2/3] checksum go1.26.2.darwin-arm64.tar.gz
mise go@1.26.2            [3/3] extract go1.26.2.darwin-arm64.tar.gz
mise go@1.26.2            [3/3] go version
mise go@1.26.2            [3/3] go version go1.26.2 darwin/arm64
mise go@1.26.2          ✓ installed
package client // import "github.com/gravitational/teleport/api/client"

func (c *Client) GetUser(ctx context.Context, name string, withSecrets bool) (types.User, error)
    GetUser returns a list of usernames registered in the system. withSecrets
    controls whether authentication details are returned.

---
zsh: command not found: go
---
zsh: command not found: go
---
zsh: command not found: go


mise exec -- go doc github.com/gravitational/teleport/api/client.Client.UpdateUser 2>&1 | head -15 && echo "---GetTraits---" && mise exec -- go doc github.com/gravitational/teleport/api/types.User.GetTraits 2>&1 | head -10 && echo "---SetTraits---" && mise exec -- go doc github.com/gravitational/teleport/api/types.User.SetTraits 2>&1 | head -10 && echo "---UpsertUser---" && mise exec -- go doc github.com/gravitational/teleport/api/client.Client.UpsertUser 2>&1 | head -15

Check Teleport UpdateUser/traits signatures


mise temporal@1.5.1       [1/3] install
mise temporal@1.5.1       [1/3] download temporal_cli_1.5.1_darwin_arm64.tar.gz
mise temporal@1.5.1       [2/3] checksum temporal_cli_1.5.1_darwin_arm64.tar.gz
mise temporal@1.5.1       [2/3] verify GitHub attestations
mise temporal@1.5.1       [2/3] verify SLSA provenance
mise temporal@1.5.1       [3/3] extract temporal_cli_1.5.1_darwin_arm64.tar.gz
mise temporal@1.5.1     ✓ installed
package client // import "github.com/gravitational/teleport/api/client"

func (c *Client) UpdateUser(ctx context.Context, user types.User) (types.User, error)
    UpdateUser updates an existing user in a backend.

---GetTraits---
package types // import "github.com/gravitational/teleport/api/types"

type User interface {

	// GetTraits gets the trait map for this user used to populate role variables.
	GetTraits() map[string][]string
}
---SetTraits---
package types // import "github.com/gravitational/teleport/api/types"

type User interface {

	// SetTraits sets the trait map for this user used to populate role variables.
	SetTraits(map[string][]string)
}
---UpsertUser---
package client // import "github.com/gravitational/teleport/api/client"

func (c *Client) UpsertUser(ctx context.Context, user types.User) (types.User, error)
    UpsertUser creates a new user or updates an existing user.


Good — API confirmed. Writing the config extension now.


Write

package config

import (
	"errors"
	"fmt"
	"os"
	"strconv"
)

const (
	ProviderAnthropic = "anthropic"
	ProviderBedrock   = "bedrock"

	defaultModelAnthropic = "claude-sonnet-4-6"
	// Bedrock model IDs use cross-region inference profile format.
	// Override with CLAUDE_MODEL if you need a different version.
	defaultModelBedrock = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"

	defaultDeltaLow    = 1
	defaultDeltaMedium = 0
	defaultDeltaHigh   = -2
	defaultRepMin      = -20
	defaultRepMax      = 20
	defaultRepStart    = 0
)

// Reputation controls how assessments adjust per-user reputation scores.
type Reputation struct {
	DeltaLow    int // applied when the assessment is low risk
	DeltaMedium int // applied when the assessment is medium risk
	DeltaHigh   int // applied when the assessment is high risk
	Min         int // lower clamp
	Max         int // upper clamp
	Start       int // starting score for a user with no existing trait
}

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	LLMProvider          string // "anthropic" (default) or "bedrock"
	AnthropicAPIKey      string // required when LLMProvider == "anthropic"
	BedrockRegion        string // optional; falls back to AWS_DEFAULT_REGION / profile
	ClaudeModel          string
	OTLPEndpoint         string // OTEL_EXPORTER_OTLP_ENDPOINT; empty disables metric shipping
	Reputation           Reputation
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		LLMProvider:          os.Getenv("LLM_PROVIDER"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		BedrockRegion:        os.Getenv("BEDROCK_REGION"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
		OTLPEndpoint:         os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
	}

	if cfg.LLMProvider == "" {
		cfg.LLMProvider = ProviderAnthropic
	}

	if cfg.ClaudeModel == "" {
		switch cfg.LLMProvider {
		case ProviderBedrock:
			cfg.ClaudeModel = defaultModelBedrock
		default:
			cfg.ClaudeModel = defaultModelAnthropic
		}
	}

	rep, err := loadReputation()
	if err != nil {
		return nil, err
	}
	cfg.Reputation = rep

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}

	switch cfg.LLMProvider {
	case ProviderAnthropic:
		if cfg.AnthropicAPIKey == "" {
			return nil, errors.New("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")
		}
	case ProviderBedrock:
		// credentials come from the standard AWS credential chain
	default:
		return nil, errors.New("LLM_PROVIDER must be 'anthropic' or 'bedrock'")
	}

	return cfg, nil
}

func loadReputation() (Reputation, error) {
	rep := Reputation{
		DeltaLow:    defaultDeltaLow,
		DeltaMedium: defaultDeltaMedium,
		DeltaHigh:   defaultDeltaHigh,
		Min:         defaultRepMin,
		Max:         defaultRepMax,
		Start:       defaultRepStart,
	}

	fields := []struct {
		env string
		dst *int
	}{
		{"REPUTATION_DELTA_LOW", &rep.DeltaLow},
		{"REPUTATION_DELTA_MEDIUM", &rep.DeltaMedium},
		{"REPUTATION_DELTA_HIGH", &rep.DeltaHigh},
		{"REPUTATION_MIN", &rep.Min},
		{"REPUTATION_MAX", &rep.Max},
		{"REPUTATION_START", &rep.Start},
	}

	for _, f := range fields {
		v := os.Getenv(f.env)
		if v == "" {
			continue
		}
		n, err := strconv.Atoi(v)
		if err != nil {
			return Reputation{}, fmt.Errorf("%s must be an integer: %w", f.env, err)
		}
		*f.dst = n
	}

	if rep.Min > rep.Max {
		return Reputation{}, fmt.Errorf("REPUTATION_MIN (%d) must be <= REPUTATION_MAX (%d)", rep.Min, rep.Max)
	}
	if rep.Start < rep.Min || rep.Start > rep.Max {
		return Reputation{}, fmt.Errorf("REPUTATION_START (%d) must be within [%d, %d]", rep.Start, rep.Min, rep.Max)
	}

	return rep, nil
}


The file /Users/steve.briggs/projects/operations/services/televibe/internal/config/config.go has been updated successfully.

package config

import (
	"errors"
	"fmt"
	"os"
	"strconv"
)

const (
	ProviderAnthropic = "anthropic"
	ProviderBedrock   = "bedrock"

	defaultModelAnthropic = "claude-sonnet-4-6"
	// Bedrock model IDs use cross-region inference profile format.
	// Override with CLAUDE_MODEL if you need a different version.
	defaultModelBedrock = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"

	defaultDeltaLow    = 1
	defaultDeltaMedium = 0
	defaultDeltaHigh   = -2
	defaultRepMin      = -20
	defaultRepMax      = 20
	defaultRepStart    = 0
)

// Reputation controls how assessments adjust per-user reputation scores.
type Reputation struct {
	DeltaLow    int // applied when the assessment is low risk
	DeltaMedium int // applied when the assessment is medium risk
	DeltaHigh   int // applied when the assessment is high risk
	Min         int // lower clamp
	Max         int // upper clamp
	Start       int // starting score for a user with no existing trait
}

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	LLMProvider          string // "anthropic" (default) or "bedrock"
	AnthropicAPIKey      string // required when LLMProvider == "anthropic"
	BedrockRegion        string // optional; falls back to AWS_DEFAULT_REGION / profile
	ClaudeModel          string
	OTLPEndpoint         string // OTEL_EXPORTER_OTLP_ENDPOINT; empty disables metric shipping
	Reputation           Reputation
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		LLMProvider:          os.Getenv("LLM_PROVIDER"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		BedrockRegion:        os.Getenv("BEDROCK_REGION"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
		OTLPEndpoint:         os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
	}

	if cfg.LLMProvider == "" {
		cfg.LLMProvider = ProviderAnthropic
	}

	if cfg.ClaudeModel == "" {
		switch cfg.LLMProvider {
		case ProviderBedrock:
			cfg.ClaudeModel = defaultModelBedrock
		default:
			cfg.ClaudeModel = defaultModelAnthropic
		}
	}

	rep, err := loadReputation()
	if err != nil {
		return nil, err
	}
	cfg.Reputation = rep

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}

	switch cfg.LLMProvider {
	case ProviderAnthropic:
		if cfg.AnthropicAPIKey == "" {
			return nil, errors.New("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")
		}
	case ProviderBedrock:
		// credentials come from the standard AWS credential chain
	default:
		return nil, errors.New("LLM_PROVIDER must be 'anthropic' or 'bedrock'")
	}

	return cfg, nil
}

func loadReputation() (Reputation, error) {
	rep := Reputation{
		DeltaLow:    defaultDeltaLow,
		DeltaMedium: defaultDeltaMedium,
		DeltaHigh:   defaultDeltaHigh,
		Min:         defaultRepMin,
		Max:         defaultRepMax,
		Start:       defaultRepStart,
	}

	fields := []struct {
		env string
		dst *int
	}{
		{"REPUTATION_DELTA_LOW", &rep.DeltaLow},
		{"REPUTATION_DELTA_MEDIUM", &rep.DeltaMedium},
		{"REPUTATION_DELTA_HIGH", &rep.DeltaHigh},
		{"REPUTATION_MIN", &rep.Min},
		{"REPUTATION_MAX", &rep.Max},
		{"REPUTATION_START", &rep.Start},
	}

	for _, f := range fields {
		v := os.Getenv(f.env)
		if v == "" {
			continue
		}
		n, err := strconv.Atoi(v)
		if err != nil {
			return Reputation{}, fmt.Errorf("%s must be an integer: %w", f.env, err)
		}
		*f.dst = n
	}

	if rep.Min > rep.Max {
		return Reputation{}, fmt.Errorf("REPUTATION_MIN (%d) must be <= REPUTATION_MAX (%d)", rep.Min, rep.Max)
	}
	if rep.Start < rep.Min || rep.Start > rep.Max {
		return Reputation{}, fmt.Errorf("REPUTATION_START (%d) must be within [%d, %d]", rep.Start, rep.Min, rep.Max)
	}

	return rep, nil
}

import (
"errors"
+  "fmt"
"os"
+  "strconv"
)
const (

+// Reputation controls how assessments adjust per-user reputation scores.

+  DeltaLow    int // applied when the assessment is low risk

+  DeltaMedium int // applied when the assessment is medium risk

+  DeltaHigh   int // applied when the assessment is high risk

+  Start       int // starting score for a user with no existing trait

+  OTLPEndpoint         string // OTEL_EXPORTER_OTLP_ENDPOINT; empty disables metric shipping

ClaudeModel:          os.Getenv("CLAUDE_MODEL"),

+    OTLPEndpoint:         os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),

+func loadReputation() (Reputation, error) {

+    {"REPUTATION_DELTA_LOW", &rep.DeltaLow},

+    {"REPUTATION_DELTA_MEDIUM", &rep.DeltaMedium},

+    {"REPUTATION_DELTA_HIGH", &rep.DeltaHigh},

+      return Reputation{}, fmt.Errorf("%s must be an integer: %w", f.env, err)

+    return Reputation{}, fmt.Errorf("REPUTATION_MIN (%d) must be <= REPUTATION_MAX (%d)", rep.Min, rep.Max)

+  if rep.Start < rep.Min || rep.Start > rep.Max {

+    return Reputation{}, fmt.Errorf("REPUTATION_START (%d) must be within [%d, %d]", rep.Start, rep.Min, rep.Max)

package config

import (
	"errors"
	"os"
)

const (
	ProviderAnthropic = "anthropic"
	ProviderBedrock   = "bedrock"

	defaultModelAnthropic = "claude-sonnet-4-6"
	// Bedrock model IDs use cross-region inference profile format.
	// Override with CLAUDE_MODEL if you need a different version.
	defaultModelBedrock = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
)

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	LLMProvider          string // "anthropic" (default) or "bedrock"
	AnthropicAPIKey      string // required when LLMProvider == "anthropic"
	BedrockRegion        string // optional; falls back to AWS_DEFAULT_REGION / profile
	ClaudeModel          string
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		LLMProvider:          os.Getenv("LLM_PROVIDER"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		BedrockRegion:        os.Getenv("BEDROCK_REGION"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
	}

	if cfg.LLMProvider == "" {
		cfg.LLMProvider = ProviderAnthropic
	}

	if cfg.ClaudeModel == "" {
		switch cfg.LLMProvider {
		case ProviderBedrock:
			cfg.ClaudeModel = defaultModelBedrock
		default:
			cfg.ClaudeModel = defaultModelAnthropic
		}
	}

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}

	switch cfg.LLMProvider {
	case ProviderAnthropic:
		if cfg.AnthropicAPIKey == "" {
			return nil, errors.New("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")
		}
	case ProviderBedrock:
		// credentials come from the standard AWS credential chain
	default:
		return nil, errors.New("LLM_PROVIDER must be 'anthropic' or 'bedrock'")
	}

	return cfg, nil
}


package config

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
	allKeys := []string{
		"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE",
		"LLM_PROVIDER", "ANTHROPIC_API_KEY", "BEDROCK_REGION", "CLAUDE_MODEL",
		"OTEL_EXPORTER_OTLP_ENDPOINT",
		"REPUTATION_DELTA_LOW", "REPUTATION_DELTA_MEDIUM", "REPUTATION_DELTA_HIGH",
		"REPUTATION_MIN", "REPUTATION_MAX", "REPUTATION_START",
	}

	base := map[string]string{
		"TELEPORT_ADDR":          "tp.example.com:443",
		"TELEPORT_IDENTITY_FILE": "/id.pem",
		"ANTHROPIC_API_KEY":      "sk-ant-key",
	}

	tests := []struct {
		name              string
		env               map[string]string
		wantErr           string
		wantProvider      string
		wantModel         string
		wantBedrockRegion string
		wantOTLP          string
		wantRep           Reputation
	}{
		{
			name:    "missing TELEPORT_ADDR",
			env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_ADDR is required",
		},
		{
			name:    "missing TELEPORT_IDENTITY_FILE",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_IDENTITY_FILE is required",
		},
		{
			name:    "missing ANTHROPIC_API_KEY with default provider",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
			wantErr: "ANTHROPIC_API_KEY is required",
		},
		{
			name:    "invalid LLM_PROVIDER",
			env:     merge(base, map[string]string{"LLM_PROVIDER": "openai"}),
			wantErr: "LLM_PROVIDER must be",
		},
		{
			name:         "defaults (anthropic)",
			env:          base,
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
			wantRep:      defaultReputation(),
		},
		{
			name:         "explicit anthropic provider",
			env:          merge(base, map[string]string{"LLM_PROVIDER": "anthropic"}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
			wantRep:      defaultReputation(),
		},
		{
			name:         "bedrock provider, no API key required",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock"},
			wantProvider: ProviderBedrock,
			wantModel:    defaultModelBedrock,
			wantRep:      defaultReputation(),
		},
		{
			name:              "bedrock provider with region",
			env:               map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "BEDROCK_REGION": "eu-west-1"},
			wantProvider:      ProviderBedrock,
			wantModel:         defaultModelBedrock,
			wantBedrockRegion: "eu-west-1",
			wantRep:           defaultReputation(),
		},
		{
			name:         "custom model overrides provider default",
			env:          merge(base, map[string]string{"CLAUDE_MODEL": "claude-opus-4-7"}),
			wantProvider: ProviderAnthropic,
			wantModel:    "claude-opus-4-7",
			wantRep:      defaultReputation(),
		},
		{
			name:         "bedrock with custom model",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "CLAUDE_MODEL": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"},
			wantProvider: ProviderBedrock,
			wantModel:    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
			wantRep:      defaultReputation(),
		},
		{
			name:         "OTLP endpoint propagated",
			env:          merge(base, map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317"}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
			wantOTLP:     "localhost:4317",
			wantRep:      defaultReputation(),
		},
		{
			name: "custom reputation deltas and range",
			env: merge(base, map[string]string{
				"REPUTATION_DELTA_LOW":    "2",
				"REPUTATION_DELTA_MEDIUM": "-1",
				"REPUTATION_DELTA_HIGH":   "-5",
				"REPUTATION_MIN":          "-50",
				"REPUTATION_MAX":          "50",
				"REPUTATION_START":        "10",
			}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
			wantRep: Reputation{
				DeltaLow: 2, DeltaMedium: -1, DeltaHigh: -5,
				Min: -50, Max: 50, Start: 10,
			},
		},
		{
			name:    "non-integer reputation delta",
			env:     merge(base, map[string]string{"REPUTATION_DELTA_LOW": "half"}),
			wantErr: "REPUTATION_DELTA_LOW must be an integer",
		},
		{
			name:    "min greater than max",
			env:     merge(base, map[string]string{"REPUTATION_MIN": "10", "REPUTATION_MAX": "5"}),
			wantErr: "REPUTATION_MIN (10) must be <= REPUTATION_MAX (5)",
		},
		{
			name:    "start outside clamp",
			env:     merge(base, map[string]string{"REPUTATION_START": "100"}),
			wantErr: "REPUTATION_START (100) must be within",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			for _, key := range allKeys {
				t.Setenv(key, "")
			}
			for k, v := range tt.env {
				t.Setenv(k, v)
			}

			cfg, err := Load()

			if tt.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.wantProvider, cfg.LLMProvider)
			assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
			assert.Equal(t, tt.wantBedrockRegion, cfg.BedrockRegion)
			assert.Equal(t, tt.wantOTLP, cfg.OTLPEndpoint)
			assert.Equal(t, tt.wantRep, cfg.Reputation)
		})
	}
}

func defaultReputation() Reputation {
	return Reputation{
		DeltaLow:    defaultDeltaLow,
		DeltaMedium: defaultDeltaMedium,
		DeltaHigh:   defaultDeltaHigh,
		Min:         defaultRepMin,
		Max:         defaultRepMax,
		Start:       defaultRepStart,
	}
}

func merge(base, overrides map[string]string) map[string]string {
	out := make(map[string]string, len(base)+len(overrides))
	for k, v := range base {
		out[k] = v
	}
	for k, v := range overrides {
		out[k] = v
	}
	return out
}


The file /Users/steve.briggs/projects/operations/services/televibe/internal/config/config_test.go has been updated successfully.

package config

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
	allKeys := []string{
		"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE",
		"LLM_PROVIDER", "ANTHROPIC_API_KEY", "BEDROCK_REGION", "CLAUDE_MODEL",
		"OTEL_EXPORTER_OTLP_ENDPOINT",
		"REPUTATION_DELTA_LOW", "REPUTATION_DELTA_MEDIUM", "REPUTATION_DELTA_HIGH",
		"REPUTATION_MIN", "REPUTATION_MAX", "REPUTATION_START",
	}

	base := map[string]string{
		"TELEPORT_ADDR":          "tp.example.com:443",
		"TELEPORT_IDENTITY_FILE": "/id.pem",
		"ANTHROPIC_API_KEY":      "sk-ant-key",
	}

	tests := []struct {
		name              string
		env               map[string]string
		wantErr           string
		wantProvider      string
		wantModel         string
		wantBedrockRegion string
		wantOTLP          string
		wantRep           Reputation
	}{
		{
			name:    "missing TELEPORT_ADDR",
			env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_ADDR is required",
		},
		{
			name:    "missing TELEPORT_IDENTITY_FILE",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_IDENTITY_FILE is required",
		},
		{
			name:    "missing ANTHROPIC_API_KEY with default provider",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
			wantErr: "ANTHROPIC_API_KEY is required",
		},
		{
			name:    "invalid LLM_PROVIDER",
			env:     merge(base, map[string]string{"LLM_PROVIDER": "openai"}),
			wantErr: "LLM_PROVIDER must be",
		},
		{
			name:         "defaults (anthropic)",
			env:          base,
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
			wantRep:      defaultReputation(),
		},
		{
			name:         "explicit anthropic provider",
			env:          merge(base, map[string]string{"LLM_PROVIDER": "anthropic"}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
			wantRep:      defaultReputation(),
		},
		{
			name:         "bedrock provider, no API key required",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock"},
			wantProvider: ProviderBedrock,
			wantModel:    defaultModelBedrock,
			wantRep:      defaultReputation(),
		},
		{
			name:              "bedrock provider with region",
			env:               map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "BEDROCK_REGION": "eu-west-1"},
			wantProvider:      ProviderBedrock,
			wantModel:         defaultModelBedrock,
			wantBedrockRegion: "eu-west-1",
			wantRep:           defaultReputation(),
		},
		{
			name:         "custom model overrides provider default",
			env:          merge(base, map[string]string{"CLAUDE_MODEL": "claude-opus-4-7"}),
			wantProvider: ProviderAnthropic,
			wantModel:    "claude-opus-4-7",
			wantRep:      defaultReputation(),
		},
		{
			name:         "bedrock with custom model",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "CLAUDE_MODEL": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"},
			wantProvider: ProviderBedrock,
			wantModel:    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
			wantRep:      defaultReputation(),
		},
		{
			name:         "OTLP endpoint propagated",
			env:          merge(base, map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317"}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
			wantOTLP:     "localhost:4317",
			wantRep:      defaultReputation(),
		},
		{
			name: "custom reputation deltas and range",
			env: merge(base, map[string]string{
				"REPUTATION_DELTA_LOW":    "2",
				"REPUTATION_DELTA_MEDIUM": "-1",
				"REPUTATION_DELTA_HIGH":   "-5",
				"REPUTATION_MIN":          "-50",
				"REPUTATION_MAX":          "50",
				"REPUTATION_START":        "10",
			}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
			wantRep: Reputation{
				DeltaLow: 2, DeltaMedium: -1, DeltaHigh: -5,
				Min: -50, Max: 50, Start: 10,
			},
		},
		{
			name:    "non-integer reputation delta",
			env:     merge(base, map[string]string{"REPUTATION_DELTA_LOW": "half"}),
			wantErr: "REPUTATION_DELTA_LOW must be an integer",
		},
		{
			name:    "min greater than max",
			env:     merge(base, map[string]string{"REPUTATION_MIN": "10", "REPUTATION_MAX": "5"}),
			wantErr: "REPUTATION_MIN (10) must be <= REPUTATION_MAX (5)",
		},
		{
			name:    "start outside clamp",
			env:     merge(base, map[string]string{"REPUTATION_START": "100"}),
			wantErr: "REPUTATION_START (100) must be within",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			for _, key := range allKeys {
				t.Setenv(key, "")
			}
			for k, v := range tt.env {
				t.Setenv(k, v)
			}

			cfg, err := Load()

			if tt.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.wantProvider, cfg.LLMProvider)
			assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
			assert.Equal(t, tt.wantBedrockRegion, cfg.BedrockRegion)
			assert.Equal(t, tt.wantOTLP, cfg.OTLPEndpoint)
			assert.Equal(t, tt.wantRep, cfg.Reputation)
		})
	}
}

func defaultReputation() Reputation {
	return Reputation{
		DeltaLow:    defaultDeltaLow,
		DeltaMedium: defaultDeltaMedium,
		DeltaHigh:   defaultDeltaHigh,
		Min:         defaultRepMin,
		Max:         defaultRepMax,
		Start:       defaultRepStart,
	}
}

func merge(base, overrides map[string]string) map[string]string {
	out := make(map[string]string, len(base)+len(overrides))
	for k, v := range base {
		out[k] = v
	}
	for k, v := range overrides {
		out[k] = v
	}
	return out
}

+    "REPUTATION_DELTA_LOW", "REPUTATION_DELTA_MEDIUM", "REPUTATION_DELTA_HIGH",

+    "REPUTATION_MIN", "REPUTATION_MAX", "REPUTATION_START",

+    env               map[string]string

+      wantRep:      defaultReputation(),

name:         "explicit anthropic provider",

name:              "bedrock provider with region",

+      wantRep:           defaultReputation(),

name:         "bedrock with custom model",

+      name:         "OTLP endpoint propagated",

+      env:          merge(base, map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317"}),

+      wantModel:    defaultModelAnthropic,

+      name: "custom reputation deltas and range",

+      env: merge(base, map[string]string{

+        "REPUTATION_DELTA_LOW":    "2",

+        "REPUTATION_DELTA_MEDIUM": "-1",

+        "REPUTATION_DELTA_HIGH":   "-5",

+        "REPUTATION_MIN":          "-50",

+        "REPUTATION_MAX":          "50",

+        "REPUTATION_START":        "10",

+        DeltaLow: 2, DeltaMedium: -1, DeltaHigh: -5,

+      name:    "non-integer reputation delta",

+      env:     merge(base, map[string]string{"REPUTATION_DELTA_LOW": "half"}),

+      wantErr: "REPUTATION_DELTA_LOW must be an integer",

+      env:     merge(base, map[string]string{"REPUTATION_MIN": "10", "REPUTATION_MAX": "5"}),

+      wantErr: "REPUTATION_MIN (10) must be <= REPUTATION_MAX (5)",

+      env:     merge(base, map[string]string{"REPUTATION_START": "100"}),

+      wantErr: "REPUTATION_START (100) must be within",

+      assert.Equal(t, tt.wantOTLP, cfg.OTLPEndpoint)

+      assert.Equal(t, tt.wantRep, cfg.Reputation)

package config

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
	allKeys := []string{
		"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE",
		"LLM_PROVIDER", "ANTHROPIC_API_KEY", "BEDROCK_REGION", "CLAUDE_MODEL",
	}

	base := map[string]string{
		"TELEPORT_ADDR":          "tp.example.com:443",
		"TELEPORT_IDENTITY_FILE": "/id.pem",
		"ANTHROPIC_API_KEY":      "sk-ant-key",
	}

	tests := []struct {
		name             string
		env              map[string]string
		wantErr          string
		wantProvider     string
		wantModel        string
		wantBedrockRegion string
	}{
		{
			name:    "missing TELEPORT_ADDR",
			env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_ADDR is required",
		},
		{
			name:    "missing TELEPORT_IDENTITY_FILE",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_IDENTITY_FILE is required",
		},
		{
			name:    "missing ANTHROPIC_API_KEY with default provider",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
			wantErr: "ANTHROPIC_API_KEY is required",
		},
		{
			name:    "invalid LLM_PROVIDER",
			env:     merge(base, map[string]string{"LLM_PROVIDER": "openai"}),
			wantErr: "LLM_PROVIDER must be",
		},
		{
			name:         "defaults (anthropic)",
			env:          base,
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
		},
		{
			name:         "explicit anthropic provider",
			env:          merge(base, map[string]string{"LLM_PROVIDER": "anthropic"}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
		},
		{
			name:         "bedrock provider, no API key required",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock"},
			wantProvider: ProviderBedrock,
			wantModel:    defaultModelBedrock,
		},
		{
			name:              "bedrock provider with region",
			env:               map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "BEDROCK_REGION": "eu-west-1"},
			wantProvider:      ProviderBedrock,
			wantModel:         defaultModelBedrock,
			wantBedrockRegion: "eu-west-1",
		},
		{
			name:         "custom model overrides provider default",
			env:          merge(base, map[string]string{"CLAUDE_MODEL": "claude-opus-4-7"}),
			wantProvider: ProviderAnthropic,
			wantModel:    "claude-opus-4-7",
		},
		{
			name:         "bedrock with custom model",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "CLAUDE_MODEL": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"},
			wantProvider: ProviderBedrock,
			wantModel:    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			for _, key := range allKeys {
				t.Setenv(key, "")
			}
			for k, v := range tt.env {
				t.Setenv(k, v)
			}

			cfg, err := Load()

			if tt.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.wantProvider, cfg.LLMProvider)
			assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
			assert.Equal(t, tt.wantBedrockRegion, cfg.BedrockRegion)
		})
	}
}

func merge(base, overrides map[string]string) map[string]string {
	out := make(map[string]string, len(base)+len(overrides))
	for k, v := range base {
		out[k] = v
	}
	for k, v := range overrides {
		out[k] = v
	}
	return out
}


Updated task #3 status


Bash

mise exec -- go doc github.com/gravitational/teleport/api/types.NewUser 2>&1 | head -15

Check NewUser constructor


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

func NewUser(name string) (User, error)
    NewUser creates new empty user


Write

// Package reputation maintains a per-user reputation score in Teleport user
// traits based on evaluated access request risk. Scores are read and written
// as string traits so they surface to approvers in the Teleport UI.
package reputation

import (
	"context"
	"fmt"
	"strconv"
	"sync"
	"time"

	"github.com/gravitational/teleport/api/types"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
)

const (
	TraitScore     = "reputation_score"
	TraitUpdatedAt = "reputation_updated_at"
	TraitLastDelta = "reputation_last_delta"
)

// UserClient is the subset of the Teleport API the manager needs.
type UserClient interface {
	GetUser(ctx context.Context, name string, withSecrets bool) (types.User, error)
	UpdateUser(ctx context.Context, user types.User) (types.User, error)
}

// Update describes the result of applying an assessment to a user's score.
type Update struct {
	User     string
	Previous int
	Delta    int
	New      int
}

// Manager updates per-user reputation traits based on assessment outcomes.
type Manager struct {
	client UserClient
	cfg    config.Reputation
	now    func() time.Time

	mu    sync.Mutex
	locks map[string]*sync.Mutex
}

func New(client UserClient, cfg config.Reputation) *Manager {
	return &Manager{
		client: client,
		cfg:    cfg,
		now:    time.Now,
		locks:  make(map[string]*sync.Mutex),
	}
}

// Compute returns the new (clamped) score and the delta applied, given the
// previous score, the risk level of the assessment, and the reputation config.
// Exposed for unit tests and callers that want to preview the result.
func Compute(prev int, level evaluator.RiskLevel, cfg config.Reputation) (next, delta int) {
	switch level {
	case evaluator.RiskLow:
		delta = cfg.DeltaLow
	case evaluator.RiskHigh:
		delta = cfg.DeltaHigh
	default:
		delta = cfg.DeltaMedium
	}
	return clamp(prev+delta, cfg.Min, cfg.Max), delta
}

// Apply reads the user's current reputation, computes and writes the new
// score, and returns what changed. Concurrent Apply calls for the same user
// are serialized to avoid clobbering writes.
func (m *Manager) Apply(ctx context.Context, username string, a *evaluator.Assessment) (Update, error) {
	lock := m.userLock(username)
	lock.Lock()
	defer lock.Unlock()

	user, err := m.client.GetUser(ctx, username, false)
	if err != nil {
		return Update{}, fmt.Errorf("get user %q: %w", username, err)
	}

	traits := user.GetTraits()
	if traits == nil {
		traits = make(map[string][]string)
	}

	prev := m.cfg.Start
	if existing, ok := firstTrait(traits, TraitScore); ok {
		if n, err := strconv.Atoi(existing); err == nil {
			prev = clamp(n, m.cfg.Min, m.cfg.Max)
		}
	}

	next, delta := Compute(prev, a.RiskLevel, m.cfg)

	traits[TraitScore] = []string{strconv.Itoa(next)}
	traits[TraitUpdatedAt] = []string{m.now().UTC().Format(time.RFC3339)}
	traits[TraitLastDelta] = []string{strconv.Itoa(delta)}
	user.SetTraits(traits)

	if _, err := m.client.UpdateUser(ctx, user); err != nil {
		return Update{}, fmt.Errorf("update user %q: %w", username, err)
	}

	return Update{User: username, Previous: prev, Delta: delta, New: next}, nil
}

func (m *Manager) userLock(user string) *sync.Mutex {
	m.mu.Lock()
	defer m.mu.Unlock()
	l, ok := m.locks[user]
	if !ok {
		l = &sync.Mutex{}
		m.locks[user] = l
	}
	return l
}

func firstTrait(t map[string][]string, key string) (string, bool) {
	v, ok := t[key]
	if !ok || len(v) == 0 {
		return "", false
	}
	return v[0], true
}

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