Can we add an option for this teleport plugin to only log what the decisions would be instead of taking action on them? A dry-run option if you will, it should be configurable via the configuration file and environment variable


- 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. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- 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


Bash

Show current directory and contents


/Users/steve.briggs/projects/operations/apps/televibe
drwxr-xr-x steve.briggs staff 288 B  Wed Apr 22 16:44:47 2026 ..
.rw-r--r-- steve.briggs staff 4.0 MB Wed Apr 22 16:44:47 2026 logo.png
drwxr-xr-x steve.briggs staff  96 B  Thu Apr 23 11:03:39 2026 .claude
.rw-r--r-- steve.briggs staff 1.8 KB Thu Apr 23 11:28:11 2026 televibe.toml
.rw-r--r-- steve.briggs staff  12 KB Thu Apr 23 11:28:17 2026 .televibe.toml.swp
drwxr-xr-x steve.briggs staff 128 B  Thu Apr 23 11:43:08 2026 cmd
.rwxr-xr-x steve.briggs staff  58 MB Thu Apr 23 11:43:19 2026 submit
.rw-r--r-- steve.briggs staff  16 KB Thu Apr 23 17:05:40 2026 .app.go.swp
drwxr-xr-x steve.briggs staff 224 B  Fri Apr 24 13:45:53 2026 .idea
drwxr-xr-x steve.briggs staff 160 B  Fri Apr 24 13:51:26 2026 risk
.rw-r--r-- steve.briggs staff  18 KB Fri Apr 24 14:00:42 2026 go.sum
drwxr-xr-x steve.briggs staff 192 B  Fri Apr 24 14:18:19 2026 ai
.rw-r--r-- steve.briggs staff  26 B  Fri Apr 24 15:41:37 2026 .gitignore
.rw-r--r-- steve.briggs staff 2.6 KB Fri Apr 24 15:45:33 2026 env.go
.rw-r--r-- steve.briggs staff 3.6 KB Fri Apr 24 15:46:07 2026 env_test.go
.rw-r--r-- steve.briggs staff 1.6 KB Fri Apr 24 17:09:59 2026 app_test.go
.rw-r--r-- steve.briggs staff 2.1 KB Fri Apr 24 17:17:01 2026 config.go
.rw-r--r-- steve.briggs staff 3.7 KB Fri Apr 24 17:17:33 2026 config_test.go
drwxr-xr-x steve.briggs staff 128 B  Fri Apr 24 17:27:41 2026 reputation
drwxr-xr-x steve.briggs staff 128 B  Fri Apr 24 17:30:40 2026 jira
drwxr-xr-x steve.briggs staff 416 B  Fri Apr 24 17:35:37 2026 local
drwxr-xr-x steve.briggs staff  96 B  Fri Apr 24 17:39:14 2026 telemetry
.rw-r--r-- steve.briggs staff 8.7 KB Fri Apr 24 17:39:33 2026 app.go
.rw-r--r-- steve.briggs staff 3.4 KB Fri Apr 24 17:39:44 2026 televibe.toml.example
.rw-r--r-- steve.briggs staff 9.3 KB Fri Apr 24 17:50:16 2026 README.md
drwxr-xr-x steve.briggs staff 864 B  Fri Apr 24 17:50:16 2026 .
.rw-r--r-- steve.briggs staff 3.6 KB Fri Apr 24 17:57:28 2026 go.mod


Can we add an option for this teleport plugin to only log what the decisions would be instead of taking action on them? A dry-run option if you will, it should be configurable via the configuration fi…


1	package televibe
2
3	import (
4		"fmt"
5		"os"
6
7		"github.com/pelletier/go-toml/v2"
8
9		"televibe/ai"
10		"televibe/jira"
11		"televibe/reputation"
12		"televibe/risk"
13		"televibe/telemetry"
14	)
15
16	type Config struct {
17		Teleport   TeleportConfig    `toml:"teleport"`
18		AI         ai.Config         `toml:"ai"`
19		Jira       jira.Config       `toml:"jira"`
20		Risk       risk.Config       `toml:"risk"`
21		Reputation reputation.Config `toml:"reputation"`
22		Telemetry  telemetry.Config  `toml:"telemetry"`
23		Log        LogConfig         `toml:"log"`
24	}
25
26	type TeleportConfig struct {
27		Addr         string `toml:"addr"`
28		IdentityFile string `toml:"identity_file"`
29	}
30
31	type LogConfig struct {
32		Level string `toml:"level"`
33	}
34
35	func LoadConfig(path string) (*Config, error) {
36		data, err := os.ReadFile(path)
37		if err != nil {
38			return nil, fmt.Errorf("read config: %w", err)
39		}
40		// Defaults go first so that TOML and env can override them — including
41		// with explicit zero values. The reverse order conflates "unset" with
42		// "set to 0", silently resurrecting defaults for fields a user meant
43		// to disable (e.g. reputation.penalty_missing_custx = 0).
44		cfg := &Config{}
45		cfg.applyDefaults()
46		if err := toml.Unmarshal(data, cfg); err != nil {
47			return nil, fmt.Errorf("parse config: %w", err)
48		}
49		if err := applyEnv(cfg); err != nil {
50			return nil, fmt.Errorf("apply env overrides: %w", err)
51		}
52		if err := cfg.validate(); err != nil {
53			return nil, err
54		}
55		return cfg, nil
56	}
57
58	func (c *Config) applyDefaults() {
59		c.Jira.ApplyDefaults()
60		c.Risk.ApplyDefaults()
61		c.Reputation.ApplyDefaults()
62		c.Telemetry.ApplyDefaults()
63		if c.Log.Level == "" {
64			c.Log.Level = "info"
65		}
66	}
67
68	func (c *Config) validate() error {
69		if c.Teleport.Addr == "" {
70			return fmt.Errorf("teleport.addr is required")
71		}
72		if c.Teleport.IdentityFile == "" {
73			return fmt.Errorf("teleport.identity_file is required")
74		}
75		if err := c.AI.Validate(); err != nil {
76			return fmt.Errorf("ai: %w", err)
77		}
78		if err := c.Jira.Validate(); err != nil {
79			return fmt.Errorf("jira: %w", err)
80		}
81		if err := c.Reputation.Validate(); err != nil {
82			return fmt.Errorf("reputation: %w", err)
83		}
84		return nil
85	}
86

package televibe

import (
	"fmt"
	"os"

	"github.com/pelletier/go-toml/v2"

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

type Config struct {
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}

type TeleportConfig struct {
	Addr         string `toml:"addr"`
	IdentityFile string `toml:"identity_file"`
}

type LogConfig struct {
	Level string `toml:"level"`
}

func LoadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	// Defaults go first so that TOML and env can override them — including
	// with explicit zero values. The reverse order conflates "unset" with
	// "set to 0", silently resurrecting defaults for fields a user meant
	// to disable (e.g. reputation.penalty_missing_custx = 0).
	cfg := &Config{}
	cfg.applyDefaults()
	if err := toml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}
	if err := applyEnv(cfg); err != nil {
		return nil, fmt.Errorf("apply env overrides: %w", err)
	}
	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return cfg, nil
}

func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	c.Reputation.ApplyDefaults()
	c.Telemetry.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}

func (c *Config) validate() error {
	if c.Teleport.Addr == "" {
		return fmt.Errorf("teleport.addr is required")
	}
	if c.Teleport.IdentityFile == "" {
		return fmt.Errorf("teleport.identity_file is required")
	}
	if err := c.AI.Validate(); err != nil {
		return fmt.Errorf("ai: %w", err)
	}
	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	if err := c.Reputation.Validate(); err != nil {
		return fmt.Errorf("reputation: %w", err)
	}
	return nil
}


1	package televibe
2
3	import (
4		"context"
5		"fmt"
6		"log/slog"
7		"sync"
8		"time"
9
10		"github.com/gravitational/teleport/api/client"
11		"github.com/gravitational/teleport/api/types"
12		"golang.org/x/sync/errgroup"
13
14		"televibe/ai"
15		"televibe/jira"
16		"televibe/reputation"
17		"televibe/risk"
18		"televibe/telemetry"
19	)
20
21	type App struct {
22		cfg    *Config
23		tele   *client.Client
24		ai     ai.Provider
25		jira   *jira.Client
26		scorer *risk.Scorer
27		rep    *reputation.Store
28		meter  *telemetry.Meter
29
30		pending   map[string]string
31		pendingMu sync.Mutex
32	}
33
34	func NewApp(ctx context.Context, cfg *Config) (*App, error) {
35		tele, err := client.New(ctx, client.Config{
36			Addrs: []string{cfg.Teleport.Addr},
37			Credentials: []client.Credentials{
38				client.LoadIdentityFile(cfg.Teleport.IdentityFile),
39			},
40		})
41		if err != nil {
42			return nil, fmt.Errorf("teleport client: %w", err)
43		}
44
45		prov, err := ai.New(cfg.AI)
46		if err != nil {
47			return nil, fmt.Errorf("ai provider: %w", err)
48		}
49
50		jc, err := jira.New(cfg.Jira)
51		if err != nil {
52			return nil, fmt.Errorf("jira client: %w", err)
53		}
54
55		scorer, err := risk.NewScorer(cfg.Risk)
56		if err != nil {
57			return nil, fmt.Errorf("scorer: %w", err)
58		}
59
60		store, err := reputation.NewStore(cfg.Reputation)
61		if err != nil {
62			return nil, fmt.Errorf("reputation store: %w", err)
63		}
64
65		var meter *telemetry.Meter
66		if cfg.Telemetry.Enabled {
67			meter, err = telemetry.New(ctx, cfg.Telemetry, store)
68			if err != nil {
69				return nil, fmt.Errorf("telemetry: %w", err)
70			}
71		}
72
73		return &App{
74			cfg:     cfg,
75			tele:    tele,
76			ai:      prov,
77			jira:    jc,
78			scorer:  scorer,
79			rep:     store,
80			meter:   meter,
81			pending: map[string]string{},
82		}, nil
83	}
84
85	// Close flushes outstanding telemetry. Safe to call even if telemetry
86	// is disabled.
87	func (a *App) Close(ctx context.Context) error {
88		return a.meter.Shutdown(ctx)
89	}
90
91	func (a *App) Run(ctx context.Context) error {
92		slog.Info("televibe starting",
93			"provider", a.cfg.AI.Provider,
94			"threshold", a.cfg.Risk.TicketThreshold,
95			"itsm_project", a.cfg.Jira.Project)
96
97		if err := a.rehydrate(ctx); err != nil {
98			return fmt.Errorf("rehydrate open ITSM tickets: %w", err)
99		}
100
101		// Run watcher/poller under their own cancelable context rather than
102		// bolting them directly to the signal context. A coordinator
103		// goroutine waits for the signal, then sleeps for
104		// telemetry.shutdown_grace before cancelling the errgroup so that
105		// the OTel periodic reader can finish an in-flight export cleanly.
106		runCtx, cancelRun := context.WithCancel(context.Background())
107		defer cancelRun()
108		g, gctx := errgroup.WithContext(runCtx)
109		g.Go(func() error { return a.watchRequests(gctx) })
110		g.Go(func() error { return a.pollITSM(gctx) })
111		g.Go(func() error {
112			select {
113			case <-ctx.Done():
114				grace := time.Duration(0)
115				if a.cfg.Telemetry.Enabled {
116					grace = a.cfg.Telemetry.ShutdownGrace
117				}
118				if grace > 0 {
119					slog.Info("shutdown signal received; waiting for telemetry flush", "grace", grace)
120					select {
121					case <-time.After(grace):
122					case <-gctx.Done():
123						// errgroup collapsed for some other reason; stop waiting.
124					}
125				}
126				cancelRun()
127			case <-gctx.Done():
128			}
129			return nil
130		})
131
132		return g.Wait()
133	}
134
135	func (a *App) rehydrate(ctx context.Context) error {
136		open, err := a.jira.ListOpenTeleportTickets(ctx)
137		if err != nil {
138			return err
139		}
140		a.pendingMu.Lock()
141		for reqID, ticket := range open {
142			a.pending[reqID] = ticket
143		}
144		a.pendingMu.Unlock()
145		slog.Info("rehydrated pending tickets", "count", len(open))
146		return nil
147	}
148
149	func (a *App) watchRequests(ctx context.Context) error {
150		watcher, err := a.tele.NewWatcher(ctx, types.Watch{
151			Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
152		})
153		if err != nil {
154			return fmt.Errorf("start watcher: %w", err)
155		}
156		defer watcher.Close()
157
158		for {
159			select {
160			case <-ctx.Done():
161				return ctx.Err()
162			case <-watcher.Done():
163				return watcher.Error()
164			case ev := <-watcher.Events():
165				if ev.Type != types.OpPut {
166					continue
167				}
168				req, ok := ev.Resource.(types.AccessRequest)
169				if !ok {
170					continue
171				}
172				if req.GetState() != types.RequestState_PENDING {
173					continue
174				}
175				if !a.reserveOrSkip(req.GetName()) {
176					continue
177				}
178				go a.handleRequest(ctx, req)
179			}
180		}
181	}
182
183	// reserveOrSkip atomically claims a slot for request id. Returns true if
184	// this caller won the race and should process the request; false if the
185	// id is already in flight or already has a ticket. Using the same map
186	// for both reservations and ticket mappings keeps dedup decisions on a
187	// single source of truth.
188	func (a *App) reserveOrSkip(id string) bool {
189		a.pendingMu.Lock()
190		defer a.pendingMu.Unlock()
191		if _, held := a.pending[id]; held {
192			return false
193		}
194		a.pending[id] = ""
195		return true
196	}
197
198	func (a *App) releasePending(id string) {
199		a.pendingMu.Lock()
200		defer a.pendingMu.Unlock()
201		delete(a.pending, id)
202	}
203
204	func (a *App) recordTicket(id, ticket string) {
205		a.pendingMu.Lock()
206		defer a.pendingMu.Unlock()
207		a.pending[id] = ticket
208	}
209
210	func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
211		id := req.GetName()
212		user := req.GetUser()
213		log := slog.With("request_id", id, "user", user)
214
215		// Release the reservation unless we successfully record a ticket;
216		// that way score/Jira failures are re-tryable on the next OpPut and
217		// auto-approved requests don't leave stale entries behind.
218		handled := false
219		defer func() {
220			if !handled {
221				a.releasePending(id)
222			}
223		}()
224
225		log.Info("scoring access request")
226		result, err := a.scorer.Score(ctx, a.ai, req)
227		if err != nil {
228			log.Error("scoring failed", "err", err)
229			return
230		}
231		log.Info("risk scored",
232			"score", result.Score,
233			"tier", result.Tier.Name,
234			"components", result.Components)
235
236		assessment, err := a.rep.Assess(ctx, user, result.Components)
237		if err != nil {
238			log.Warn("reputation update failed", "err", err)
239		}
240		if len(assessment.Penalties) > 0 {
241			reasons := make([]string, 0, len(assessment.Penalties))
242			for _, p := range assessment.Penalties {
243				reasons = append(reasons, p.Reason)
244			}
245			log.Info("reputation docked",
246				"previous", assessment.Previous,
247				"current", assessment.Score,
248				"reasons", reasons)
249		}
250
251		threshold := a.cfg.Risk.TicketThreshold
252		if result.Escalation {
253			threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())
254			log.Info("session escalation detected", "threshold", threshold)
255		}
256
257		if result.Score < threshold {
258			reason := fmt.Sprintf("televibe: %s (score %d) — auto-approved",
259				result.Tier.Name, result.Score)
260			if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
261				log.Error("auto-approve failed", "err", err)
262				return
263			}
264			log.Info("access request auto-approved",
265				"score", result.Score,
266				"tier", result.Tier.Name,
267				"threshold", threshold)
268			return
269		}
270
271		ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
272			RequestID:   id,
273			User:        req.GetUser(),
274			Roles:       req.GetRoles(),
275			Reason:      req.GetRequestReason(),
276			Score:       result.Score,
277			Tier:        result.Tier.Name,
278			Components:  result.Components,
279			AIReasoning: result.Reasoning,
280			Escalation:  result.Escalation,
281		})
282		if err != nil {
283			log.Error("create ITSM ticket failed", "err", err)
284			return
285		}
286
287		a.recordTicket(id, ticket)
288		handled = true
289		log.Info("ITSM ticket created, request held for SRE review",
290			"ticket", ticket, "tier", result.Tier.Name)
291	}
292
293	func (a *App) pollITSM(ctx context.Context) error {
294		t := time.NewTicker(a.cfg.Jira.PollInterval)
295		defer t.Stop()
296		for {
297			select {
298			case <-ctx.Done():
299				return ctx.Err()
300			case <-t.C:
301				a.reconcileITSM(ctx)
302			}
303		}
304	}
305
306	func (a *App) reconcileITSM(ctx context.Context) {
307		a.pendingMu.Lock()
308		snap := make(map[string]string, len(a.pending))
309		for k, v := range a.pending {
310			snap[k] = v
311		}
312		a.pendingMu.Unlock()
313
314		for requestID, ticket := range snap {
315			if ticket == "" {
316				// Reservation for an in-flight handleRequest; nothing to poll yet.
317				continue
318			}
319			status, err := a.jira.TicketStatus(ctx, ticket)
320			if err != nil {
321				slog.Warn("ITSM status check failed", "ticket", ticket, "err", err)
322				continue
323			}
324			var next types.RequestState
325			switch status {
326			case jira.StatusApproved:
327				next = types.RequestState_APPROVED
328			case jira.StatusRejected:
329				next = types.RequestState_DENIED
330			default:
331				continue
332			}
333			reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)
334			if err := a.submitReview(ctx, requestID, next, reason); err != nil {
335				slog.Error("submit review failed", "request_id", requestID, "err", err)
336				continue
337			}
338			a.pendingMu.Lock()
339			delete(a.pending, requestID)
340			a.pendingMu.Unlock()
341		}
342	}
343
344	func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
345		_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
346			RequestID: requestID,
347			Review: types.AccessReview{
348				Author:        "televibe",
349				ProposedState: state,
350				Reason:        reason,
351			},
352		})
353		return err
354	}
355

package televibe

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

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

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

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

	pending   map[string]string
	pendingMu sync.Mutex
}

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

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

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

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

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

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

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

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

func (a *App) Run(ctx context.Context) error {
	slog.Info("televibe starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project)

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

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

	return g.Wait()
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}


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

package televibe

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

[log]
level = "info"


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

package televibe

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

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

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

[ai]
provider = "anthropic"

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

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

[risk]
ticket_threshold     = 0
escalation_threshold = 0

[reputation]
penalty_missing_custx    = 0
penalty_thin_description = 0
`)

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

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

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

[ai]
provider = "anthropic"

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

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

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

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

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

[ai]
provider = "anthropic"

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

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

[risk]
ticket_threshold = 0
`)

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


1	package televibe
2
3	import (
4		"sync"
5		"sync/atomic"
6		"testing"
7	)
8
9	func TestReserveOrSkip_ExactlyOneWinner(t *testing.T) {
10		a := &App{pending: map[string]string{}}
11
12		const goroutines = 100
13		var start sync.WaitGroup
14		var done sync.WaitGroup
15		var winners atomic.Int32
16
17		start.Add(1)
18		done.Add(goroutines)
19		for i := 0; i < goroutines; i++ {
20			go func() {
21				defer done.Done()
22				start.Wait()
23				if a.reserveOrSkip("req-1") {
24					winners.Add(1)
25				}
26			}()
27		}
28		start.Done()
29		done.Wait()
30
31		if got := winners.Load(); got != 1 {
32			t.Errorf("exactly one goroutine should reserve, got %d winners", got)
33		}
34		if _, ok := a.pending["req-1"]; !ok {
35			t.Errorf("winning goroutine should have left a reservation in place")
36		}
37	}
38
39	func TestReserveOrSkip_AfterReleaseReacquires(t *testing.T) {
40		a := &App{pending: map[string]string{}}
41		if !a.reserveOrSkip("req-1") {
42			t.Fatal("first reservation should succeed")
43		}
44		if a.reserveOrSkip("req-1") {
45			t.Fatal("second reservation should fail while held")
46		}
47		a.releasePending("req-1")
48		if !a.reserveOrSkip("req-1") {
49			t.Fatal("reservation after release should succeed (covers retry-after-failure path)")
50		}
51	}
52
53	func TestReserveOrSkip_TicketedIDIsNotReacquired(t *testing.T) {
54		a := &App{pending: map[string]string{}}
55		if !a.reserveOrSkip("req-1") {
56			t.Fatal("reservation should succeed")
57		}
58		a.recordTicket("req-1", "ITSM-42")
59		if a.reserveOrSkip("req-1") {
60			t.Fatal("request already holding a ticket must not be reserved again")
61		}
62		if got := a.pending["req-1"]; got != "ITSM-42" {
63			t.Errorf("recordTicket should leave the real key in place, got %q", got)
64		}
65	}
66

package televibe

import (
	"sync"
	"sync/atomic"
	"testing"
)

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

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

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

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

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

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


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

package televibe

import (
	"testing"
	"time"

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

func TestApplyEnv_Overrides(t *testing.T) {
	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")
	t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
	t.Setenv("TV_AI_PROVIDER", "bedrock")
	t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")
	t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
	t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")
	t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")
	t.Setenv("TV_TELEMETRY_ENABLED", "true")
	t.Setenv("TV_REPUTATION_STARTING_SCORE", "80")

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

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

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

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

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

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

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


1

2
3

4
5	# televibe
6
7	A [Teleport](https://goteleport.com) access-request plugin that routes each pending request through an AI risk analyst, auto-approves the boring ones, and files an ITSM Jira ticket for the sketchy ones — with the Teleport request held in `PENDING` until an SRE resolves the ticket.
8
9	Risk tiers:
10
11	| Score  | Tier     | Action                                 |
12	| ------ | -------- | -------------------------------------- |
13	| 0–24   | low      | auto-approve, no Jira ticket           |
14	| 25–49  | medium   | file ITSM ticket, hold request pending |
15	| 50–74  | high     | file ITSM ticket with tier label       |
16	| 75–100 | critical | file ITSM ticket with tier label       |
17
18	The ticket-creation threshold is configurable (`risk.ticket_threshold`). Session escalations — access requests carrying the `televibe.escalation` Teleport annotation — use a tighter fallback threshold (`risk.escalation_threshold`) and support per-role overrides in `risk.escalation_thresholds`.
19
20	## How it works
21
22	```
23	Teleport ──► watcher ──► scorer ──► AI provider ──► tier
24	                             │
25	                             ├── below threshold ──► auto-approve via AccessReview
26	                             │
27	                             └── at/above threshold ──► Jira ITSM ticket ──► poll status
28	                                                                              │
29	                                                         SRE approves/rejects │
30	                                                                              ▼
31	                                                              Teleport AccessReview
32	```
33
34	**Risk components:**
35
36	- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
37	- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.
38
39	## User reputation
40
41	Every requester starts at `reputation.starting_score` (default `100`) and
42	accumulates deductions for each bad request they file. Penalties and the
43	`[floor, ceiling]` clamp are fully configurable:
44
45	| Signal                 | Config key                         | Default |
46	| ---------------------- | ---------------------------------- | ------- |
47	| Missing CUSTX ticket   | `reputation.penalty_missing_custx` | `10`    |
48	| Thin (<40 char) reason | `reputation.penalty_thin_description` | `5`  |
49
50	State is persisted as a JSON map at `reputation.persist_path` via atomic
51	rename — no external database required. The file survives restarts; leave
52	`persist_path` empty to keep everything in memory.
53
54	Reputation only ever goes down. If somebody wants their number back,
55	they can file better requests next quarter. Or not.
56
57	## Telemetry / shaming pipeline
58
59	When `telemetry.enabled = true`, televibe stands up an OpenTelemetry
60	`MeterProvider` with an OTLP/gRPC exporter pointed at the configured
61	collector. It registers one observable gauge:
62
63	- `televibe.user.reputation` — int64, tagged with `user`, observed every
64	  `telemetry.export_period` (default `15s`).
65
66	Route your collector's output to Datadog (or Prometheus, or wherever) and
67	the gauge lands tagged per user, ready to be pinned to a dashboard for
68	the monthly team meeting.
69
70	## AI providers
71
72	Pick one via `ai.provider`:
73
74	- **`anthropic`** — direct call to the Anthropic Messages API. System prompt is sent with `cache_control: ephemeral` so repeated scoring hits the prompt cache.
75	- **`bedrock`** — `bedrockruntime:InvokeModel` against a Claude-on-Bedrock model. Uses the default AWS credential chain (IRSA / Pod Identity / env / profile); optional `profile` and `role_arn` for local dev and cross-account AssumeRole.
76	- **`random`** — test-only provider that fabricates assessments within valid ranges. Use for local dev and integration tests; emits a startup warning so it doesn't silently ship to prod. Accepts an optional `seed` for deterministic runs.
77
78	## Install
79
80	```sh
81	go mod tidy
82	go build -o televibe ./cmd/televibe
83	```
84
85	## Configure
86
87	Copy `televibe.toml.example` to `/etc/televibe/televibe.toml` and fill in:
88
89	- `[teleport]` — proxy address + path to an identity file for a Teleport user with permission to watch access requests and submit reviews.
90	- `[ai]` — pick a provider and supply its credentials.
91	- `[jira]` — API user, token, ITSM project key, and the status names to treat as approved/rejected (e.g. `"Approved"` / `"Rejected"`).
92	- `[risk]` — threshold, CUSTX pattern (defaults to `CUSTX-\d+`), off-hours window, timezone.
93
94	### Environment variable overrides
95
96	Every scalar TOML key can be overridden by an environment variable. Use this to keep secrets out of the TOML file (e.g. Jira API tokens, Anthropic keys), or to vary deploy-specific values per environment.
97
98	**Naming rule:** `TV_` + the TOML section name + `_` + the field's TOML key, all uppercased. Nested sections (like `ai.anthropic`) concatenate with `_`. So `ai.anthropic.api_key` becomes `TV_AI_ANTHROPIC_API_KEY`.
99
100	**Precedence:** env var > `televibe.toml` > built-in default.
101
102	**Supported types:** `string`, `bool`, integer, float, and `time.Duration` (Go duration strings — `30s`, `2m`, `1h30m`).
103
104	**Not supported:** maps (`risk.escalation_thresholds`) and slices — TOML-only.
105
106	Common examples:
107
108	| Env var                          | Overrides                                      |
109	| -------------------------------- | ---------------------------------------------- |
110	| `TV_TELEPORT_ADDR`               | `teleport.addr`                                |
111	| `TV_TELEPORT_IDENTITY_FILE`      | `teleport.identity_file`                       |
112	| `TV_AI_PROVIDER`                 | `ai.provider`                                  |
113	| `TV_AI_ANTHROPIC_API_KEY`        | `ai.anthropic.api_key`                         |
114	| `TV_AI_ANTHROPIC_MODEL`          | `ai.anthropic.model`                           |
115	| `TV_AI_BEDROCK_REGION`           | `ai.bedrock.region`                            |
116	| `TV_AI_BEDROCK_MODEL_ID`         | `ai.bedrock.model_id`                          |
117	| `TV_AI_BEDROCK_PROFILE`          | `ai.bedrock.profile`                           |
118	| `TV_AI_BEDROCK_ROLE_ARN`         | `ai.bedrock.role_arn`                          |
119	| `TV_JIRA_URL`                    | `jira.url`                                     |
120	| `TV_JIRA_USERNAME`               | `jira.username`                                |
121	| `TV_JIRA_API_TOKEN`              | `jira.api_token`                               |
122	| `TV_JIRA_PROJECT`                | `jira.project`                                 |
123	| `TV_JIRA_POLL_INTERVAL`          | `jira.poll_interval` (Go duration, e.g. `2m`)  |
124	| `TV_RISK_TICKET_THRESHOLD`       | `risk.ticket_threshold`                        |
125	| `TV_RISK_ESCALATION_ANNOTATION`  | `risk.escalation_annotation`                   |
126	| `TV_RISK_ESCALATION_THRESHOLD`   | `risk.escalation_threshold`                    |
127	| `TV_REPUTATION_PERSIST_PATH`     | `reputation.persist_path`                      |
128	| `TV_REPUTATION_STARTING_SCORE`   | `reputation.starting_score`                    |
129	| `TV_TELEMETRY_ENABLED`           | `telemetry.enabled`                            |
130	| `TV_TELEMETRY_ENDPOINT`          | `telemetry.endpoint`                           |
131	| `TV_TELEMETRY_EXPORT_PERIOD`     | `telemetry.export_period` (duration)           |
132	| `TV_TELEMETRY_SHUTDOWN_GRACE`    | `telemetry.shutdown_grace` (duration; `0s` disables the wait) |
133	| `TV_LOG_LEVEL`                   | `log.level`                                    |
134
135	An invalid value (e.g. `TV_RISK_TICKET_THRESHOLD=abc`) fails startup with the offending variable name in the error, so misconfigurations surface immediately.
136
137	### Teleport role
138
139	The plugin user needs a role roughly like:
140
141	```yaml
142	kind: role
143	version: v7
144	metadata:
145	  name: televibe-plugin
146	spec:
147	  allow:
148	    rules:
149	      - resources: [access_request]
150	        verbs: [list, read, update]
151	      - resources: [access_plugin_data]
152	        verbs: [update]
153	```
154
155	Issue an identity file with `tctl auth sign --user=televibe --out=/var/lib/teleport/televibe.pem --ttl=8760h --format=file`.
156
157	### Jira workflow
158
159	The ITSM project needs statuses matching `jira.approved_status` and `jira.rejected_status`. When an SRE transitions the ticket to either, the poller reflects that decision back to the Teleport request on its next tick (`jira.poll_interval`, default `30s`).
160
161	## Run
162
163	```sh
164	./televibe --config /etc/televibe/televibe.toml
165	```
166
167	JSON logs to stderr, level from `[log].level`.
168
169	## Layout
170
171	```
172	televibe/
173	├── cmd/televibe/main.go    entry point
174	├── config.go               TOML loader + validation
175	├── app.go                  watcher loop + ITSM poller
176	├── ai/                     Provider interface, Anthropic / Bedrock / random impls
177	├── risk/                   scorer, tier table
178	├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
179	├── reputation/             per-user score store, JSON-file persistence
180	├── telemetry/              OTLP meter provider, observable reputation gauge
181	└── televibe.toml.example
182	```
183
184	## Restart safety
185
186	On startup, `televibe` runs a JQL search for open tickets labelled `teleport` in the ITSM project and pulls the Teleport request ID out of each ticket description to rebuild the held-request map. Restarts don't duplicate tickets.
187
188	## License
189
190	TBD.
191

# televibe

A [Teleport](https://goteleport.com) access-request plugin that routes each pending request through an AI risk analyst, auto-approves the boring ones, and files an ITSM Jira ticket for the sketchy ones — with the Teleport request held in `PENDING` until an SRE resolves the ticket.

Risk tiers:

| Score  | Tier     | Action                                 |
| ------ | -------- | -------------------------------------- |
| 0–24   | low      | auto-approve, no Jira ticket           |
| 25–49  | medium   | file ITSM ticket, hold request pending |
| 50–74  | high     | file ITSM ticket with tier label       |
| 75–100 | critical | file ITSM ticket with tier label       |

The ticket-creation threshold is configurable (`risk.ticket_threshold`). Session escalations — access requests carrying the `televibe.escalation` Teleport annotation — use a tighter fallback threshold (`risk.escalation_threshold`) and support per-role overrides in `risk.escalation_thresholds`.

## How it works

```
Teleport ──► watcher ──► scorer ──► AI provider ──► tier
                             │
                             ├── below threshold ──► auto-approve via AccessReview
                             │
                             └── at/above threshold ──► Jira ITSM ticket ──► poll status
                                                                              │
                                                         SRE approves/rejects │
                                                                              ▼
                                                              Teleport AccessReview
```

**Risk components:**

- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.

## User reputation

Every requester starts at `reputation.starting_score` (default `100`) and
accumulates deductions for each bad request they file. Penalties and the
`[floor, ceiling]` clamp are fully configurable:

| Signal                 | Config key                         | Default |
| ---------------------- | ---------------------------------- | ------- |
| Missing CUSTX ticket   | `reputation.penalty_missing_custx` | `10`    |
| Thin (<40 char) reason | `reputation.penalty_thin_description` | `5`  |

State is persisted as a JSON map at `reputation.persist_path` via atomic
rename — no external database required. The file survives restarts; leave
`persist_path` empty to keep everything in memory.

Reputation only ever goes down. If somebody wants their number back,
they can file better requests next quarter. Or not.

## Telemetry / shaming pipeline

When `telemetry.enabled = true`, televibe stands up an OpenTelemetry
`MeterProvider` with an OTLP/gRPC exporter pointed at the configured
collector. It registers one observable gauge:

- `televibe.user.reputation` — int64, tagged with `user`, observed every
  `telemetry.export_period` (default `15s`).

Route your collector's output to Datadog (or Prometheus, or wherever) and
the gauge lands tagged per user, ready to be pinned to a dashboard for
the monthly team meeting.

## AI providers

Pick one via `ai.provider`:

- **`anthropic`** — direct call to the Anthropic Messages API. System prompt is sent with `cache_control: ephemeral` so repeated scoring hits the prompt cache.
- **`bedrock`** — `bedrockruntime:InvokeModel` against a Claude-on-Bedrock model. Uses the default AWS credential chain (IRSA / Pod Identity / env / profile); optional `profile` and `role_arn` for local dev and cross-account AssumeRole.
- **`random`** — test-only provider that fabricates assessments within valid ranges. Use for local dev and integration tests; emits a startup warning so it doesn't silently ship to prod. Accepts an optional `seed` for deterministic runs.

## Install

```sh
go mod tidy
go build -o televibe ./cmd/televibe
```

## Configure

Copy `televibe.toml.example` to `/etc/televibe/televibe.toml` and fill in:

- `[teleport]` — proxy address + path to an identity file for a Teleport user with permission to watch access requests and submit reviews.
- `[ai]` — pick a provider and supply its credentials.
- `[jira]` — API user, token, ITSM project key, and the status names to treat as approved/rejected (e.g. `"Approved"` / `"Rejected"`).
- `[risk]` — threshold, CUSTX pattern (defaults to `CUSTX-\d+`), off-hours window, timezone.

### Environment variable overrides

Every scalar TOML key can be overridden by an environment variable. Use this to keep secrets out of the TOML file (e.g. Jira API tokens, Anthropic keys), or to vary deploy-specific values per environment.

**Naming rule:** `TV_` + the TOML section name + `_` + the field's TOML key, all uppercased. Nested sections (like `ai.anthropic`) concatenate with `_`. So `ai.anthropic.api_key` becomes `TV_AI_ANTHROPIC_API_KEY`.

**Precedence:** env var > `televibe.toml` > built-in default.

**Supported types:** `string`, `bool`, integer, float, and `time.Duration` (Go duration strings — `30s`, `2m`, `1h30m`).

**Not supported:** maps (`risk.escalation_thresholds`) and slices — TOML-only.

Common examples:

| Env var                          | Overrides                                      |
| -------------------------------- | ---------------------------------------------- |
| `TV_TELEPORT_ADDR`               | `teleport.addr`                                |
| `TV_TELEPORT_IDENTITY_FILE`      | `teleport.identity_file`                       |
| `TV_AI_PROVIDER`                 | `ai.provider`                                  |
| `TV_AI_ANTHROPIC_API_KEY`        | `ai.anthropic.api_key`                         |
| `TV_AI_ANTHROPIC_MODEL`          | `ai.anthropic.model`                           |
| `TV_AI_BEDROCK_REGION`           | `ai.bedrock.region`                            |
| `TV_AI_BEDROCK_MODEL_ID`         | `ai.bedrock.model_id`                          |
| `TV_AI_BEDROCK_PROFILE`          | `ai.bedrock.profile`                           |
| `TV_AI_BEDROCK_ROLE_ARN`         | `ai.bedrock.role_arn`                          |
| `TV_JIRA_URL`                    | `jira.url`                                     |
| `TV_JIRA_USERNAME`               | `jira.username`                                |
| `TV_JIRA_API_TOKEN`              | `jira.api_token`                               |
| `TV_JIRA_PROJECT`                | `jira.project`                                 |
| `TV_JIRA_POLL_INTERVAL`          | `jira.poll_interval` (Go duration, e.g. `2m`)  |
| `TV_RISK_TICKET_THRESHOLD`       | `risk.ticket_threshold`                        |
| `TV_RISK_ESCALATION_ANNOTATION`  | `risk.escalation_annotation`                   |
| `TV_RISK_ESCALATION_THRESHOLD`   | `risk.escalation_threshold`                    |
| `TV_REPUTATION_PERSIST_PATH`     | `reputation.persist_path`                      |
| `TV_REPUTATION_STARTING_SCORE`   | `reputation.starting_score`                    |
| `TV_TELEMETRY_ENABLED`           | `telemetry.enabled`                            |
| `TV_TELEMETRY_ENDPOINT`          | `telemetry.endpoint`                           |
| `TV_TELEMETRY_EXPORT_PERIOD`     | `telemetry.export_period` (duration)           |
| `TV_TELEMETRY_SHUTDOWN_GRACE`    | `telemetry.shutdown_grace` (duration; `0s` disables the wait) |
| `TV_LOG_LEVEL`                   | `log.level`                                    |

An invalid value (e.g. `TV_RISK_TICKET_THRESHOLD=abc`) fails startup with the offending variable name in the error, so misconfigurations surface immediately.

### Teleport role

The plugin user needs a role roughly like:

```yaml
kind: role
version: v7
metadata:
  name: televibe-plugin
spec:
  allow:
    rules:
      - resources: [access_request]
        verbs: [list, read, update]
      - resources: [access_plugin_data]
        verbs: [update]
```

Issue an identity file with `tctl auth sign --user=televibe --out=/var/lib/teleport/televibe.pem --ttl=8760h --format=file`.

### Jira workflow

The ITSM project needs statuses matching `jira.approved_status` and `jira.rejected_status`. When an SRE transitions the ticket to either, the poller reflects that decision back to the Teleport request on its next tick (`jira.poll_interval`, default `30s`).

## Run

```sh
./televibe --config /etc/televibe/televibe.toml
```

JSON logs to stderr, level from `[log].level`.

## Layout

```
televibe/
├── cmd/televibe/main.go    entry point
├── config.go               TOML loader + validation
├── app.go                  watcher loop + ITSM poller
├── ai/                     Provider interface, Anthropic / Bedrock / random impls
├── risk/                   scorer, tier table
├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
├── reputation/             per-user score store, JSON-file persistence
├── telemetry/              OTLP meter provider, observable reputation gauge
└── televibe.toml.example
```

## Restart safety

On startup, `televibe` runs a JQL search for open tickets labelled `teleport` in the ITSM project and pulls the Teleport request ID out of each ticket description to rebuild the held-request map. Restarts don't duplicate tickets.

## License

TBD.


I've got a good picture of the codebase. Plan: add a top-level `DryRun` flag to `Config` (env: `TV_DRY_RUN`), gate `submitReview` and the Jira ticket-creation call on it, log what would have happened, then update docs and tests.

Let me look at a couple more things to make sure I'm not missing anything obvious.


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

Find all action sites that should be gated by dry-run


/Users/steve.briggs/projects/operations/apps/televibe/app.go:260:		if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
/Users/steve.briggs/projects/operations/apps/televibe/app.go:271:	ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
/Users/steve.briggs/projects/operations/apps/televibe/app.go:334:		if err := a.submitReview(ctx, requestID, next, reason); err != nil {
/Users/steve.briggs/projects/operations/apps/televibe/app.go:344:func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
/Users/steve.briggs/projects/operations/apps/televibe/app.go:345:	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
/Users/steve.briggs/projects/operations/apps/televibe/jira/itsm.go:106:func (c *Client) CreateITSMTicket(ctx context.Context, in TicketInput) (string, error) {


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


TaskCreate

Add DryRun bool field to top-level Config in config.go with toml:"dry_run"

Add DryRun field to Config


Task #1 created successfully: Add DryRun field to Config

Add DryRun field to Config


In app.go, gate submitReview and CreateITSMTicket on cfg.DryRun, log decisions instead, and add dry_run to startup banner

Gate Teleport/Jira mutations on DryRun


Task #2 created successfully: Gate Teleport/Jira mutations on DryRun

Gate Teleport/Jira mutations on DryRun


Update televibe.toml.example and README.md to document dry_run / TV_DRY_RUN

Document dry_run option


Task #3 created successfully: Document dry_run option

Document dry_run option


Add tests: env var override (TV_DRY_RUN=true) in env_test.go, and submitReview-skips-when-dry in app_test.go

Add tests for dry-run


Task #4 created successfully: Add tests for dry-run

Add tests for dry-run


Run go build and go test -race ./... in apps/televibe to verify the change

Build and test


Task #5 created successfully: Build and test

Build and test


Updated task #1 status


Edit

type Config struct {
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}

type Config struct {
	// DryRun, when true, logs the decision televibe would make for each
	// access request without calling Teleport or Jira. Useful for previewing
	// behavior on a new threshold/config before going live.
	DryRun     bool              `toml:"dry_run"`
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}


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

type Config struct {
	// DryRun, when true, logs the decision televibe would make for each
	// access request without calling Teleport or Jira. Useful for previewing
	// behavior on a new threshold/config before going live.
	DryRun     bool              `toml:"dry_run"`
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}

package televibe

import (
	"fmt"
	"os"

	"github.com/pelletier/go-toml/v2"

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

type Config struct {
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}

type TeleportConfig struct {
	Addr         string `toml:"addr"`
	IdentityFile string `toml:"identity_file"`
}

type LogConfig struct {
	Level string `toml:"level"`
}

func LoadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	// Defaults go first so that TOML and env can override them — including
	// with explicit zero values. The reverse order conflates "unset" with
	// "set to 0", silently resurrecting defaults for fields a user meant
	// to disable (e.g. reputation.penalty_missing_custx = 0).
	cfg := &Config{}
	cfg.applyDefaults()
	if err := toml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}
	if err := applyEnv(cfg); err != nil {
		return nil, fmt.Errorf("apply env overrides: %w", err)
	}
	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return cfg, nil
}

func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	c.Reputation.ApplyDefaults()
	c.Telemetry.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}

func (c *Config) validate() error {
	if c.Teleport.Addr == "" {
		return fmt.Errorf("teleport.addr is required")
	}
	if c.Teleport.IdentityFile == "" {
		return fmt.Errorf("teleport.identity_file is required")
	}
	if err := c.AI.Validate(); err != nil {
		return fmt.Errorf("ai: %w", err)
	}
	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	if err := c.Reputation.Validate(); err != nil {
		return fmt.Errorf("reputation: %w", err)
	}
	return nil
}

+  // DryRun, when true, logs the decision televibe would make for each

+  // access request without calling Teleport or Jira. Useful for previewing

+  // behavior on a new threshold/config before going live.

+  DryRun     bool              `toml:"dry_run"`

AI         ai.Config         `toml:"ai"`

Jira       jira.Config       `toml:"jira"`


Updated task #2 status


Now gate the action sites in app.go.


	slog.Info("televibe starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project)

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


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

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

package televibe

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

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

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

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

	pending   map[string]string
	pendingMu sync.Mutex
}

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

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

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

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

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

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

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

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

func (a *App) Run(ctx context.Context) error {
	slog.Info("televibe starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project)

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

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

	return g.Wait()
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}

-    "itsm_project", a.cfg.Jira.Project)

+    "itsm_project", a.cfg.Jira.Project,

+    slog.Warn("dry-run enabled: no Teleport reviews or Jira tickets will be submitted")


	ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
		RequestID:   id,
		User:        req.GetUser(),
		Roles:       req.GetRoles(),
		Reason:      req.GetRequestReason(),
		Score:       result.Score,
		Tier:        result.Tier.Name,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
		Escalation:  result.Escalation,
	})

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

	ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
		RequestID:   id,
		User:        req.GetUser(),
		Roles:       req.GetRoles(),
		Reason:      req.GetRequestReason(),
		Score:       result.Score,
		Tier:        result.Tier.Name,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
		Escalation:  result.Escalation,
	})