Skip to content

feat(413): tool registry, policy engine, and approvals strip - #418

Merged
clcollins merged 1 commit into
mainfrom
srepd/ai-p3-tools-policy
Aug 5, 2026
Merged

feat(413): tool registry, policy engine, and approvals strip#418
clcollins merged 1 commit into
mainfrom
srepd/ai-p3-tools-policy

Conversation

@clcollins

@clcollins clcollins commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 3 of the AI rearchitecture: adds a read-only tool layer, pure policy engine, approvals strip, and watcher investigation loop.

  • Tool registry (pkg/ai/tools/): 7 read-only tools wrapping existing PD/OCM accessors, exposed as anthropic.BetaTool with policy-gated execution
  • Policy engine (pkg/ai/policy/): pure Decide(cfg, toolName, class, input) → Allow/Deny/Ask — no I/O, exhaustively tested
  • Approvals strip (pkg/tui/approvals.go): TUI component for Ask decisions with keyboard navigation, accept/dismiss actions, expanded list view
  • Investigation loop (pkg/tui/investigation.go): bounded BetaToolRunner.NextMessage loop with verdict extraction (silent/noteworthy/actionable)
  • Config: watcher_max_tool_turns, watcher_investigation_timeout, ai_permission_mode, ai_auto_allow_tools, ai_allowed_command_prefixes
  • Non-Anthropic degradation: graceful fallback to synthesis-only with one-time log line
  • SDK upgrade: anthropic-sdk-go v1.57.0 → v1.61.0

Validation findings fixed (PR #418 fix round)

BLOCKING

Finding Fix
Approvals strip wired but Accept/Dismiss are no-ops Wired buildAskFromVerdict with real actions (PostNote, re-escalate, clipboard), keyboard nav (j/k/Enter/d/Esc), A key toggle
No production-path headline test TestWatcherInvestigateCmd_DenyEverything_ProductionPath and TestWatcherInvestigateCmd_AllowPath_HandlerRuns use httptest.NewServer
Allow path test missing TestGatedBetaTools_AllowPath_HandlerRuns and TestGatedBetaTools_AllowPath_Truncation in integration_test.go
Raw SDK errors logged at Warn Demoted to Debug, Warn uses ClassifyProviderError (investigation.go + tui.go)

MUST-FIX

Finding Fix
Truncate panics when maxBytes < marker Added guards for maxBytes <= 0 and maxBytes <= len(marker)
BetaTools/AsBetaTools ungated Deleted AsBetaTools() and ungatedTool entirely; updated revert-check test to use GatedBetaTools with Allow
formatError discards error Logs real error at Debug, appends classified error category to visible string
RenderExpanded never called Wired via approvalsExpanded toggle on A key press

NICE-TO-HAVE

Finding Fix
ModeCustom/ModeAuto identical without comment Added intentional-identity comment in policy.go
Config doc says "Never set to 0 (unlimited)" Fixed to "Values ≤ 0 are clamped to 6" in config.go and ai-agents.md
list_queue unbounded Capped at 25 incidents pre-marshal
investigationMsg.fullText set but never read Removed dead field
Plan doc missing post-mortem Added post-mortem / lessons learned section

Validation findings fixed (PR #418 round 3 — mutation coverage)

BLOCKING

Finding Fix
Ask.Action wiring untested — replacing DraftNote's Action with a compiling no-op leaves entire pkg/tui suite passing Added 6 tests in ask_wiring_test.go: each kind's Action invoked and observable effect asserted (PD mock call, clipboard msg, re-escalate msg). Mutation-kills verified for all 3 kinds.

MUST-FIX

Finding Fix
inferAskKind fallback could produce nil Action for unhandled kind Added default case in buildAskFromVerdict switch falling back to DraftNote behavior. Tested via TestBuildAskFromVerdict_UnknownText_FallbackHasAction and TestBuildAskFromVerdict_UnhandledKind_FallbackAction.

NICE-TO-HAVE

Finding Fix
Empty approvals queue sets status but polling overwrites it Changed to flashNotification (4s durable) instead of setStatus
ModeAuto/ModeCustom + AllowedCommandPrefixes scaffolding Comment already exists in pkg/ai/policy/policy.go:70-71 — no change needed

Validation findings fixed (PR #418 round 5 — M3 + doc accuracy)

BLOCKING

Finding Fix
Tool-permission ask path (toolAsks loop in tui.go) completely untested — replacing loop body with _ = ta compiles and all tests pass Added 3 tests in approvals_update_test.go: TestUpdate_InvestigationMsg_ToolAsksAddToolPermissionAsks (2 distinct tools → 2 AskToolPermission), TestUpdate_InvestigationMsg_EmptyToolAsksAddsNoToolPermissionAsks (empty → zero), TestUpdate_InvestigationMsg_ToolAsksWithVerdictAction (Action + toolAsk → both types). Revert check verified.

MUST-FIX

Finding Fix
Plan doc references deleted AsBetaTools() at 3 locations as if it still exists Removed/reworded all 3 references; post-mortem section documenting its removal is preserved
PR traceability table cites inexact test names (TestDecide, TestParseWatcherVerdict) Corrected to TestDecide_Exhaustive and TestParseWatcherVerdict_ValidTiers; all 37 rows re-verified mechanically via grep -rn 'func <Name>('

Call-site audit

grep -n 'approvals\.Add' pkg/tui/*.go shows exactly 2 production call sites:

  • tui.go:731 — verdict Action path (covered by round 4's TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk)
  • tui.go:735 — toolAsks loop (covered by round 5's TestUpdate_InvestigationMsg_ToolAsksAddToolPermissionAsks)

No fourth gap found.

Headline criterion

A Deny-everything policy config produces ZERO tool handler invocations.

Verified by TestHeadline_DenyEverything_ZeroHandlerInvocations (unit) and TestWatcherInvestigateCmd_DenyEverything_ProductionPath (production-path with httptest.NewServer).

Criterion → Test traceability

Criterion Test File
Deny-everything → zero handler calls TestHeadline_DenyEverything_ZeroHandlerInvocations pkg/ai/tools/integration_test.go
Policy gate is load-bearing TestRevertCheck_PolicyGate pkg/ai/tools/integration_test.go
Allow path fires handler TestGatedBetaTools_AllowPath_HandlerRuns pkg/ai/tools/integration_test.go
Allow path truncates result TestGatedBetaTools_AllowPath_Truncation pkg/ai/tools/integration_test.go
Production-path deny (httptest) TestWatcherInvestigateCmd_DenyEverything_ProductionPath pkg/tui/investigation_test.go
Production-path allow (httptest) TestWatcherInvestigateCmd_AllowPath_HandlerRuns pkg/tui/investigation_test.go
Truncate panic with small maxBytes TestTruncate_SmallMaxBytes_NoPanic pkg/ai/tools/handlers_test.go
formatError includes error class TestHandler_FormatErrorIncludesClass pkg/ai/tools/handlers_test.go
Approvals renders actual count TestApprovalsStrip_RenderShowsActualCount pkg/tui/approvals_test.go
DraftNote accept invokes PD mock TestApprovalsStrip_DraftNoteAcceptInvokesPDMock pkg/tui/approvals_test.go
RenderExpanded shows asks TestApprovalsStrip_RenderExpandedShowsAsks pkg/tui/approvals_test.go
Move selection (j/k) TestApprovalsStrip_MoveSelection pkg/tui/approvals_test.go
Accept selected ask TestApprovalsStrip_AcceptSelected pkg/tui/approvals_test.go
ClassRead allowed in every mode TestDecide_ClassReadAllowedInEveryMode pkg/ai/policy/policy_test.go
Every Mode×Class combo TestDecide_Exhaustive table (21 cases) pkg/ai/policy/policy_test.go
Registry rejects duplicates TestRegistry_DuplicateRejection pkg/ai/tools/registry_test.go
BetaTools shape TestRegistry_BetaToolsShape pkg/ai/tools/registry_test.go
Gated Ask calls callback TestGatedBetaTools_AskCallsCallback pkg/ai/tools/integration_test.go
PD tool happy paths TestGetIncident_HappyPath, etc. pkg/ai/tools/handlers_test.go
Error sanitization (token-leak) TestHandler_ErrorDoesNotLeakInternals pkg/ai/tools/handlers_test.go
Verdict parsing (all tiers) TestParseWatcherVerdict_ValidTiers table pkg/ai/tools/verdict_test.go
Approvals Add/Count/Dismiss TestApprovalsStrip_AddAndCount, TestApprovalsStrip_DismissRemoves pkg/tui/approvals_test.go
DraftNote Action calls PD add-note TestBuildAskFromVerdict_DraftNote_ActionCallsPDAddNote pkg/tui/ask_wiring_test.go
SuggestedCommand Action copies, not executes TestBuildAskFromVerdict_SuggestedCommand_CopiesNotExecutes pkg/tui/ask_wiring_test.go
EscalationSuggestion Action re-escalates TestBuildAskFromVerdict_EscalationSuggestion_ReEscalates pkg/tui/ask_wiring_test.go
inferAskKind fallback → AskDraftNote with Action TestBuildAskFromVerdict_UnknownText_FallbackHasAction pkg/tui/ask_wiring_test.go
inferAskKind keyword classification TestInferAskKind_Fallback_IsSensibleDefault pkg/tui/ask_wiring_test.go
Unhandled kind gets fallback Action TestBuildAskFromVerdict_UnhandledKind_FallbackAction pkg/tui/ask_wiring_test.go
investigationMsg → approvals strip (actionable) TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk pkg/tui/approvals_update_test.go
investigationMsg silent → no ask TestUpdate_InvestigationMsg_SilentVerdictDoesNotAddAsk pkg/tui/approvals_update_test.go
investigationMsg noteworthy → no ask TestUpdate_InvestigationMsg_NoteworthyVerdictDoesNotAddAsk pkg/tui/approvals_update_test.go
Approvals Enter returns action Cmd that posts PD note TestUpdate_ApprovalsEnter_ReturnsCmdThatPostsNote pkg/tui/approvals_update_test.go
Approvals Dismiss does not invoke action TestUpdate_ApprovalsDismiss_DoesNotReturnActionCmd pkg/tui/approvals_update_test.go
toolAsks → AskToolPermission asks TestUpdate_InvestigationMsg_ToolAsksAddToolPermissionAsks pkg/tui/approvals_update_test.go
Empty toolAsks → no AskToolPermission TestUpdate_InvestigationMsg_EmptyToolAsksAddsNoToolPermissionAsks pkg/tui/approvals_update_test.go
Verdict Action + toolAsks → both ask types TestUpdate_InvestigationMsg_ToolAsksWithVerdictAction pkg/tui/approvals_update_test.go

Revert checks

Round 2

Check Stubbed Test that failed Result
Policy gate (Deny path) Removed Deny branch in gatedTool.Execute TestWatcherInvestigateCmd_DenyEverything_ProductionPath FAILED as expected (handler invoked under deny config)
Allow path (handler execution) Stubbed handler return in gatedTool.Execute TestWatcherInvestigateCmd_AllowPath_HandlerRuns FAILED as expected (zero handler calls)

Round 3 — Ask.Action wiring

Each kind's Action was replaced with a compiling no-op (_ = x; return nil) and the test suite run:

DraftNote mutation:

=== RUN   TestBuildAskFromVerdict_DraftNote_ActionCallsPDAddNote
    ask_wiring_test.go:33: Expected value not to be nil.
        Messages:   DraftNote Action() must return a non-nil Cmd
--- FAIL: TestBuildAskFromVerdict_DraftNote_ActionCallsPDAddNote (0.00s)

SuggestedCommand mutation:

=== RUN   TestBuildAskFromVerdict_SuggestedCommand_CopiesNotExecutes
    ask_wiring_test.go:61: Expected value not to be nil.
        Messages:   SuggestedCommand Action() must return a non-nil Cmd
--- FAIL: TestBuildAskFromVerdict_SuggestedCommand_CopiesNotExecutes (0.00s)

EscalationSuggestion mutation:

=== RUN   TestBuildAskFromVerdict_EscalationSuggestion_ReEscalates
    ask_wiring_test.go:92: Expected value not to be nil.
        Messages:   EscalationSuggestion Action() must return a non-nil Cmd
--- FAIL: TestBuildAskFromVerdict_EscalationSuggestion_ReEscalates (0.00s)

All 3 mutations caught. Real code restored, all tests pass.

Round 4 — Update-level mutation coverage

Two remaining links in the AI-verdict → user-approval → PagerDuty-write chain that survived all prior tests.

M1 — investigationMsg → approvals strip (tui.go:731):
Mutation: m.approvals.Add(ask)_ = ask

=== RUN   TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk
    approvals_update_test.go:38:
        	Error Trace:	/workspace/github.com__clcollins__srepd/pkg/tui/approvals_update_test.go:38
        	Error:      	Not equal:
        	            	expected: 1
        	            	actual  : 0
        	Test:       	TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk
        	Messages:   	actionable verdict with non-empty Action must add exactly one ask to the strip
--- FAIL: TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk (0.00s)

M2 — approvals Enter handler drops its tea.Cmd (msgHandlers.go:1126):
Mutation: return m, cmd_ = m.approvals.Accept(...); return m, nil

=== RUN   TestUpdate_ApprovalsEnter_ReturnsCmdThatPostsNote
    approvals_update_test.go:122:
        	Error Trace:	/workspace/github.com__clcollins__srepd/pkg/tui/approvals_update_test.go:122
        	Error:      	Expected value not to be nil.
        	Test:       	TestUpdate_ApprovalsEnter_ReturnsCmdThatPostsNote
        	Messages:   	Enter on an ask must return the action's tea.Cmd, not nil
--- FAIL: TestUpdate_ApprovalsEnter_ReturnsCmdThatPostsNote (0.00s)

Both mutations caught. Real code restored, all tests pass.

Round 5 — toolAsks loop (M3)

Mutation: replaced m.approvals.Add(Ask{...}) loop body with _ = ta

=== RUN   TestUpdate_InvestigationMsg_ToolAsksAddToolPermissionAsks
    approvals_update_test.go:165:
        	Error Trace:	/workspace/github.com__clcollins__srepd/pkg/tui/approvals_update_test.go:165
        	Error:      	Not equal:
        	            	expected: 2
        	            	actual  : 0
        	Test:       	TestUpdate_InvestigationMsg_ToolAsksAddToolPermissionAsks
        	Messages:   	two toolAsks must produce exactly two AskToolPermission asks
--- FAIL: TestUpdate_InvestigationMsg_ToolAsksAddToolPermissionAsks (0.00s)
=== RUN   TestUpdate_InvestigationMsg_ToolAsksWithVerdictAction
    approvals_update_test.go:228:
        	Error Trace:	/workspace/github.com__clcollins__srepd/pkg/tui/approvals_update_test.go:228
        	Error:      	Not equal:
        	            	expected: 2
        	            	actual  : 1
        	Test:       	TestUpdate_InvestigationMsg_ToolAsksWithVerdictAction
        	Messages:   	verdict Action + one toolAsk must produce two asks total
--- FAIL: TestUpdate_InvestigationMsg_ToolAsksWithVerdictAction (0.00s)

Both tests FAIL with the mutation. Real code restored, all tests pass.

Deviations from plan

  • get_recent_events omitted: pkg/delta does not exist (plan 412 didn't build it)
  • Used BetaToolRunner.NextMessage (per-turn control) instead of Run() (no policy gate possible)
  • formatError drops raw error text from tool results but logs at Debug and appends classified category
  • Deleted AsBetaTools() entirely rather than gating (zero production callers = latent policy bypass)

Security properties preserved

  • Plan-101 token-leak: no response bodies in error strings (formatError drops raw error, ClassifyProviderError at Warn)
  • No secrets in test fixtures (mock IDs: "P000001", "CLUSTER001", etc.)
  • No //nolint, no panic() in library code
  • MaxIterations never set to 0
  • No replace directives, no vendoring
  • No new dependencies added (SDK upgrade only)

Test plan

  • go test ./pkg/ai/policy/... -count=1 — all pass
  • go test ./pkg/ai/tools/... -count=1 — all pass
  • go test ./pkg/tui/... -count=1 — all pass
  • go vet ./... — clean
  • gofmt -s -l cmd pkg — clean
  • golangci-lint run --timeout 5m — 0 issues
  • go test -race ./pkg/ai/... ./pkg/tui/... -count=1 — clean
  • deadcode ./... — no new deadcode from our changes (32 pre-existing entries)
  • make readme-check — OK
  • make quickstart-check — OK
  • make quickstart-verify — OK
  • cmd test failure is pre-existing (TestConfigureLogging_SetsLogWriter — read-only filesystem)

Visual validation

tui-mcp is not available in this container environment (no npx). Golden snapshots and integration tests are the only visual evidence. The changes in this round are purely behavioral (Action wiring) with no rendering changes.

🤖 Generated with Claude Code

Maintainer review findings fixed (PR #418 round 6)

F1 — BLOCKING: configured model never reaches investigation

Problem: watcher.go:249 passed "" as model to watcherInvestigateCmd.
investigation.go:107-109 substituted "claude-sonnet-4-6" — a bare model ID
that fails on Bedrock (requires inference-profile ID us.anthropic.claude-sonnet-4-6).

Fix:

  • Added ModelReporter optional interface to ai.Provider (follows HealthChecker pattern)
  • Implemented Model() on anthropicProvider (used by all 3 Anthropic-family providers)
  • Added ResolvedModel(p) helper for callers
  • runDetectors now passes ai.ResolvedModel(m.aiProvider) instead of ""
  • Replaced hardcoded fallback with a clear error on empty model
Criterion Test File
Explicit model passes through TestWatcherInvestigateCmd_ModelPlumbing_ExplicitModel pkg/tui/investigation_test.go
Empty model returns error TestWatcherInvestigateCmd_ModelPlumbing_EmptyModelReturnsError pkg/tui/investigation_test.go
Anthropic provider exposes configured model TestResolvedModel/anthropic_provider_exposes_configured_model pkg/ai/provider_test.go
Bedrock yields inference-profile ID TestResolvedModel/bedrock_provider_yields_inference-profile_ID_not_bare_model pkg/ai/provider_test.go
Non-ModelReporter returns empty TestResolvedModel/non-ModelReporter_provider_returns_empty pkg/ai/provider_test.go
Nil provider returns empty TestResolvedModel/nil_provider_returns_empty pkg/ai/provider_test.go

Revert checks

Re-introduced the hardcoded fallback (model = "claude-sonnet-4-6") at investigation.go:107-109,
confirmed TestWatcherInvestigateCmd_ModelPlumbing_EmptyModelReturnsError FAILS:

=== RUN   TestWatcherInvestigateCmd_ModelPlumbing_EmptyModelReturnsError
    investigation_test.go:249: HTTP server should not be called when model is empty
    investigation_test.go:263:
            Error Trace:    /workspace/srepd/pkg/tui/investigation_test.go:263
            Error:          "failed to get next message: ..." does not contain "model not configured"
            Test:           TestWatcherInvestigateCmd_ModelPlumbing_EmptyModelReturnsError
--- FAIL: TestWatcherInvestigateCmd_ModelPlumbing_EmptyModelReturnsError (1.46s)
FAIL

Restored the fix, confirmed tests pass.

F2 — DECISION: defer AskToolPermission to plan 415

Problem: tui.go:734-740 built AskToolPermission asks with no Action callback.
Accept returned nil — the ask silently disappeared.

Fix (deferral, not implementation):

  • Removed toolAsksAskToolPermission loop from investigationMsg handler
  • Removed AskToolPermission constant and askKindLabel case
  • Replaced 3 tests (ToolAsksAddToolPermissionAsks, EmptyToolAsksAddsNoToolPermissionAsks,
    ToolAsksWithVerdictAction) with TestUpdate_InvestigationMsg_ToolAsksAreNotSurfaced
  • M1 verdict-Action test (TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk) passes untouched
  • Updated docs/ai-agents.md interactive mode row
  • Recorded deferral in plan 413 post-mortem naming plan 415

toolAsk type and toolAsks field are still live: the onAsk collector in
investigation.go tracks ask-class decisions for diagnostic purposes and future
plan 415 consumption. deadcode ./... confirms no new entries.

Criterion Test File
Tool asks not surfaced TestUpdate_InvestigationMsg_ToolAsksAreNotSurfaced pkg/tui/approvals_update_test.go
Verdict Action still works TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk pkg/tui/approvals_update_test.go

F3 — MINOR: ask pile-up dedup

Problem: Model retrying the same tool call accumulated duplicate entries in
collectedAsks unboundedly.

Fix: Dedup by tool name + SHA-256 of input JSON, per investigation. Even
though F2 removes UI surfacing, the collector should not grow unboundedly.

Criterion Test File
Same tool+input 3× → 1 entry TestWatcherInvestigateCmd_AskDedup_SameToolAndInput pkg/tui/investigation_test.go

Test verification

$ grep -rn 'func TestResolvedModel\b' pkg/
pkg/ai/provider_test.go:68:func TestResolvedModel(t *testing.T) {

$ grep -rn 'func TestWatcherInvestigateCmd_ModelPlumbing_ExplicitModel\b' pkg/
pkg/tui/investigation_test.go:201:func TestWatcherInvestigateCmd_ModelPlumbing_ExplicitModel(t *testing.T) {

$ grep -rn 'func TestWatcherInvestigateCmd_ModelPlumbing_EmptyModelReturnsError\b' pkg/
pkg/tui/investigation_test.go:241:func TestWatcherInvestigateCmd_ModelPlumbing_EmptyModelReturnsError(t *testing.T) {

$ grep -rn 'func TestWatcherInvestigateCmd_AskDedup_SameToolAndInput\b' pkg/
pkg/tui/investigation_test.go:267:func TestWatcherInvestigateCmd_AskDedup_SameToolAndInput(t *testing.T) {

$ grep -rn 'func TestUpdate_InvestigationMsg_ToolAsksAreNotSurfaced\b' pkg/
pkg/tui/approvals_update_test.go:169:func TestUpdate_InvestigationMsg_ToolAsksAreNotSurfaced(t *testing.T) {

Round 6 CI gates

  • gofmt -s -l cmd pkg — clean
  • go vet ./... — clean
  • golangci-lint run --timeout 5m — 0 issues
  • go test ./pkg/... -count=1 — all pass
  • go test -race ./pkg/... -count=1 — clean
  • deadcode ./... — no new entries (32 pre-existing)
  • make quickstart-check — OK
  • Golden snapshots — pass (no rendering changes)
  • cmd test failure is pre-existing (TestConfigureLogging_SetsLogWriter — read-only /var/log)

Round 7 — runDetectors call-site test gap

Problem

The round-6 fix (F1) was correct, but no test exercises the actual call site
(runDetectors at watcher.go:249). The round-6 tests prove:

  • watcherInvestigateCmd uses the model it receives (TestWatcherInvestigateCmd_ModelPlumbing_ExplicitModel)
  • ResolvedModel returns the right value (TestResolvedModel)

But neither tests that runDetectors passes the right model. Confirmed:
re-introducing the exact F1 bug ("claude-sonnet-4-6" hardcoded at line 249)
left the entire test suite green.

Fix

Added TestRunDetectors_ModelPlumbing that exercises runDetectors directly:

  • Uses a bedrockProvider fake implementing ModelReporter with inference-profile ID us.anthropic.claude-sonnet-4-6
  • Sets up 3 incidents on the same service to trigger detectServiceStorm
  • Uses capturingFactory to intercept the model passed to the tool runner
  • Asserts the captured model equals the provider's resolved model
  • Asserts it is NOT the bare claude-sonnet-4-6
  • Also asserts watcherSystemPrompt, contextStr (Messages), and investigationCfg.maxToolTurns (MaxIterations) reach the runner
Criterion Test File
runDetectors passes ResolvedModel to investigation TestRunDetectors_ModelPlumbing pkg/tui/watcher_integration_test.go:897

Revert checks

Replaced ai.ResolvedModel(m.aiProvider) at watcher.go:250 with:

func() string { _ = ai.ResolvedModel(m.aiProvider); return "claude-sonnet-4-6" }()

Test result:

=== RUN   TestRunDetectors_ModelPlumbing
    watcher_integration_test.go:962:
            Error Trace:    /workspace/github.com__clcollins__srepd/pkg/tui/watcher_integration_test.go:962
            Error:          Not equal:
                            expected: "us.anthropic.claude-sonnet-4-6"
                            actual  : "claude-sonnet-4-6"
            Test:           TestRunDetectors_ModelPlumbing
            Messages:       runDetectors must pass ai.ResolvedModel(provider), not a hardcoded model ID
    watcher_integration_test.go:966:
            Error Trace:    /workspace/github.com__clcollins__srepd/pkg/tui/watcher_integration_test.go:966
            Error:          Should not be: "claude-sonnet-4-6"
            Test:           TestRunDetectors_ModelPlumbing
            Messages:       the model must be the Bedrock inference-profile ID, not the bare claude-sonnet-4-6
--- FAIL: TestRunDetectors_ModelPlumbing (0.00s)
FAIL

Restored ai.ResolvedModel(m.aiProvider), all tests pass.

Argument audit

Checked the other arguments runDetectors passes to watcherInvestigateCmd:

Argument If replaced with wrong constant, would any test fail? Status
m.toolRunnerFactory Structural — nil check in callee covers it OK (structural)
m.toolRegistry Structural — nil check in callee covers it OK (structural)
m.investigationCfg No — no test exercises this through runDetectors Now covered (MaxIterations assertion)
m.watcherSystemPrompt No — no test exercises this through runDetectors Now covered (System block assertion)
obs.Summary Comes from detectAll, not a model field — tested by detector tests OK (derived)
contextStr NobuildWatcherContext tested directly but not through runDetectors Now covered (Messages non-empty assertion)
ai.ResolvedModel(m.aiProvider) No — this was the F1 gap Now covered (primary assertion)
nil (onAsk) Always nil at this call site OK (literal)

All closeable gaps covered by extending TestRunDetectors_ModelPlumbing.

Test verification

$ grep -rn 'func TestRunDetectors_ModelPlumbing' pkg/
pkg/tui/watcher_integration_test.go:897:func TestRunDetectors_ModelPlumbing(t *testing.T) {

Round 7 CI gates

  • golangci-lint cache clean && golangci-lint run --timeout 5m — 0 issues
  • go test ./pkg/... -count=1 — all pass
  • go test -race ./pkg/... -count=1 — clean
  • deadcode ./... — no new entries (32 pre-existing)
  • make quickstart-check — OK
  • make quickstart-verify — OK

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation Review — PR #418 (Plan 413: Tools, Policy, Approvals)

Must-Fix

D1. Privacy section omits tool-investigation data flow

docs/ai-agents.md:247-259, docs/llm-providers.md:333-344

The privacy sections list incident titles, service names, alert names, and cluster IDs as the data sent to remote providers. With tool investigation, the model now autonomously fetches and sends back substantially more data: full incident payloads, alert details, notes, cluster metadata, service logs (up to 8 KB each per MaxResponseBytes), and limited-support reasons — all flowing through the Anthropic API as tool-use turns (pkg/tui/investigation.go:92-126). Neither privacy section mentions this.

Failure scenario: A user reads the privacy section, concludes only incident titles/IDs leave the machine, and enables an Anthropic-family provider in a restricted environment. The watcher autonomously pulls and transmits service logs and cluster metadata they did not intend to share.

Verified by reading: code in investigation.go and registry.go:97-106.


D2. Approvals strip says "press A" but no key handler exists

pkg/tui/approvals.go:85, pkg/tui/tui.go (missing handler)

Render() displays " ⚑ %d asks — press A " and RenderExpanded() shows [Enter] Accept [d] Dismiss [Esc] Close. No corresponding key handlers exist in tui.go for A, Enter, d, or Esc in an approvals context. The strip renders but cannot be interacted with. This is a functional gap surfaced as a docs issue — the UI text documents interaction that doesn't work.

Note: Currently all 7 tools are ClassRead (always Allow), so Ask decisions never fire and the strip never appears from policy. But tui.go:729 adds asks from actionable verdicts, so the strip can appear. If it does, the user sees "press A" and nothing happens.

Failure scenario: An actionable investigation verdict adds an ask to the strip. The user sees "press A" and presses it. Nothing happens.

Verified by: grep -rn for key handlers matching A/Enter/d/Esc in an approvals context — none found.


D3. Five new config keys missing from docs/configuration.md and README.md

docs/configuration.md:62-68, README.md:85-99

docs/configuration.md is the central config reference (README links to it as "the full reference"). Its AI Agents section only lists agent_cli_command, agent_system_prompt, and watcher_system_prompt. None of the five new keys appear: watcher_max_tool_turns, watcher_investigation_timeout, ai_permission_mode, ai_auto_allow_tools, ai_allowed_command_prefixes.

README.md has a comparable config table (lines 85-99) that also omits all five keys.

The keys ARE documented in docs/ai-agents.md:191-198 with correct defaults, so the information exists — but not in the two reference files users consult first.

Failure scenario: A user reads docs/configuration.md or the README to find all available config keys. They don't discover ai_permission_mode and unknowingly run with the default interactive mode when they intended plan.

Verified by: grep for all five key names in configuration.md and README.md — zero hits.


D4. PR body cites 5 test names that don't exist

PR body, Criterion → Test traceability table

PR body claims Actual test name File
TestGetIncident_ErrorResponse TestHandler_ErrorDoesNotLeakInternals handlers_test.go:213
TestGetIncident_Truncation TestGetServiceLogs_Truncation + TestHandler_TruncationMarker handlers_test.go:143,201
TestGetIncident_InvalidJSON TestHandler_InvalidJSON handlers_test.go:190
TestDecide (21 cases) TestDecide_Exhaustive (19 cases) policy_test.go:10
TestParseWatcherVerdict table 6 separate TestParseWatcherVerdict_* functions verdict_test.go:10-63

Failure scenario: A reviewer runs go test -run TestGetIncident_ErrorResponse to verify the token-leak property, gets 0 tests matched, and has no way to trace the claim. A future contributor reads "21 cases" and wonders why they count 19.

Verified by running: grep -rn "func TestGetIncident_ErrorResponse" → 0 hits, etc.


D5. PR body missing required sections

PR body

Required section Present?
Problem No (uses ## Summary instead)
Approach No (merged into Summary)
## Testing this live (must mention --dev) No
## Visual validation No
UNVERIFIED resolved No

The PR has a detailed traceability table and test plan, but omits the live-testing and visual-validation sections that would confirm the approvals strip and investigation loop were exercised in the real TUI.


D6. auto and custom modes are code-identical but documented differently

pkg/ai/policy/policy.go:61-77, docs/ai-agents.md:128-129

ModeAuto (lines 61-68) and ModeCustom (lines 70-77) have character-for-character identical logic. The docs describe auto as "Tools on the allowlist execute without prompting" and custom as "Fully user-defined allowlist" — but both do the same thing: ClassRead → Allow, allowlisted → Allow, else Ask. A user choosing between them has no way to know they're equivalent.

Failure scenario: A user configures custom expecting different behavior from auto, discovers it's identical, and files a bug.

Verified by: diff of the two switch branches — only the case label differs.


Nice-to-Have

D7. Plan 413 has no post-mortem section

docs/plans/413-tools-policy-approvals.md

Other recent plans (411, 412) include ## Post-mortem / Lessons learned. Plan 413 has deviations (e.g., get_recent_events omitted because pkg/delta never shipped) but no post-mortem. The deviation about plan 412 not delivering pkg/delta would be a natural lesson to capture.


D8. "(unlimited)" warning in config description is misleading

pkg/config/config.go:68

The description says "Never set to 0 (unlimited)." — the parenthetical implies 0 means unlimited. But both pkg/tui/model.go:357 (if v > 0) and investigation.go:67 (if cfg.maxToolTurns <= 0 { cfg.maxToolTurns = 6 }) clamp 0 to 6. Setting it to 0 doesn't produce unlimited behavior — the description says a bad thing happens that the code prevents.


D9. onAsk callback is nil in watcher invocation

pkg/tui/watcher.go:250

watcherInvestigateCmd is called with nil for the onAsk parameter. If a tool ever triggers an Ask policy decision, the approval request is silently swallowed — the approvals strip won't receive it. Currently safe because all 7 tools are ClassRead (always Allow), but this is a landmine for phase 4 when write-class tools are added.

Not strictly a docs issue, but the docs describe a functioning approvals flow for Ask decisions (docs/ai-agents.md:122-130) when the wiring to surface them is incomplete.


Clean

  • Config defaults: All five new keys have defaults matching between pkg/config/config.go and docs/ai-agents.md. ✓
  • Tool-support-per-provider table: docs/llm-providers.md:318-330 exists and matches isAnthropicFamily() at investigation.go:153-159. ✓
  • make quickstart-check: Passes correctly — no keymap/chords files were modified. ✓
  • Plan doc: Present at docs/plans/413-tools-policy-approvals.md with problem, solution, testing, files, and deviations. No UNVERIFIED markers remain. ✓
  • Non-Anthropic degradation: Documented in ai-agents.md:131 and llm-providers.md:320, matches code. ✓

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #418 Review — Plan 413: Tool Registry, Policy Engine, Approvals Strip

Verdict: MUST-FIX items block merge


MUST-FIX

1. Approvals strip Accept is a no-op — Action callback never wired

Severity: high | pkg/tui/tui.go:729-733

When an actionable investigation verdict fires, the Ask struct is created with Kind, Title, and Body but the Action field (a func() tea.Cmd) is never set:

m.approvals.Add(Ask{
    Kind:  AskDraftNote,
    Title: msg.verdict.Summary,
    Body:  msg.verdict.Action,  // this is the string body, NOT the func
})

approvals.go:56-65Accept() calls ask.Action() only when non-nil, so Accept silently removes the item and does nothing. The PR claims "Accept on DraftNote actually invokes the existing PD add-note path" but no such wiring exists.

Failure scenario: User sees an actionable AI recommendation, presses Accept → item disappears, no note is added to PagerDuty. The feature is decorative.

Verified by reading: production code only. No test exercises the Accept→PD path because the path does not exist.

2. No key handler for approvals strip — "press A" is a lie

Severity: high | pkg/tui/approvals.go:85, no corresponding handler anywhere

Render() displays " ⚑ N asks — press A " but there is zero key handling for the approvals strip in the Update loop. Confirmed by:

grep -rn 'approvals\.\(Accept\|Dismiss\|RenderExpanded\)' --include='*.go' | grep -v _test.go
# (no results)

Accept(), Dismiss(), and RenderExpanded() are never called from production code. The only production callers are Add() at tui.go:729, Count() and Render() at views.go:173-174.

Failure scenario: User sees the approvals bar, presses A → nothing happens. No way to interact with approval items.

3. onAsk is nil at the only call site — Ask decisions create no approval items

Severity: medium | pkg/tui/watcher.go:250

The investigation is launched with onAsk as nil:

cmds = append(cmds, watcherInvestigateCmd(
    m.toolRunnerFactory,
    m.toolRegistry,
    m.investigationCfg,
    m.watcherSystemPrompt,
    obs.Summary,
    contextStr,
    "",
    nil,   // ← onAsk
))

When the policy engine returns Ask for a tool call, gatedTool.Execute at registry.go:144 checks if g.onAsk != nil — it's nil, so no approval item is created. The tool returns "Awaiting user approval" text to the LLM and the investigation continues, but the user never sees the request.

This is less severe than #1-#2 because in the current phase all 7 tools are ClassRead, and ClassRead always returns Allow in every mode (confirmed by TestDecide_ClassReadAllowedInEveryMode). So Ask cannot fire for any registered tool in this phase. But the infrastructure is wired for future phases and will silently fail to surface Ask items.

4. PR traceability table cites 3 fabricated test names

Severity: medium — repeat of the pattern from the prior PR

Cited Name (fabricated) Actual Test Name
TestGetIncident_ErrorResponse TestHandler_ErrorDoesNotLeakInternals (handlers_test.go:213)
TestGetIncident_Truncation TestHandler_TruncationMarker (handlers_test.go:201) or TestGetServiceLogs_Truncation (handlers_test.go:143)
TestGetIncident_InvalidJSON TestHandler_InvalidJSON (handlers_test.go:190)

All three follow the same hallucination pattern: TestGetIncident_ prefix combined with a suffix from an unrelated test function.

Additionally, TestDecide should be TestDecide_Exhaustive with 19 cases (not 21 as claimed), and TestParseWatcherVerdict is not a single table test but 6 separate functions (_ValidTiers, _MissingBlock, _MalformedJSON, _ExtraKeysIgnored, _EmptyInput, _UnknownTier).

5. No test exercises watcherInvestigateCmd or verifies that policy.Decide() is wired in production

Severity: medium | pkg/tui/investigation.go:71-73

The headline test (TestHeadline_DenyEverything_ZeroHandlerInvocations) uses its own denyAll closure passed directly to GatedBetaTools(). It does NOT call policy.Decide(). The production code at investigation.go:71-73 wraps policy.Decide(cfg.policyConfig, ...) in a closure — that wiring is untested.

If someone deleted lines 71-73 and replaced the decide closure with func(...) policy.Decision { return policy.Allow }, no test would fail. Confirmed by:

  • No test file calls policy.Decide() (only policy_test.go tests it in isolation)
  • No test in pkg/tui/ exercises watcherInvestigateCmd
  • The headline test constructs its own registry, tools, and decide function independently of the production path

The test proves the gatedTool.Execute() mechanism works (which is real code at registry.go:136-160), so the enforcement layer itself is sound. But the production wiring to that layer is an untested seam — exactly the "unwired code" failure shape documented in this project's history.


NICE-TO-HAVE

6. RenderExpanded() is dead code

Severity: low | pkg/tui/approvals.go:94-125

RenderExpanded() is defined but never called from any production code. It renders a selectable list with [Enter] Accept [d] Dismiss [Esc] Close instructions, but since no key handler exists (finding #2), this code path is unreachable. Should either be wired or removed.


CLEAN (verified)

  • Tool registry: All 7 read-class tools registered (get_incident, get_alerts, get_notes, list_queue, get_cluster_info, get_service_logs, get_limited_support). Each handler calls a real PD/OCM interface method, not stubs. Duplicate-name rejection works (registry.go:44). 8192-byte truncation with \n[truncated] marker applied in both gated and ungated paths.

  • Policy engine: Decide() is pure — no I/O (policy.go:43). Default mode is ModeInteractive. ClassReadAllow in every mode. 19 exhaustive test cases cover all Mode×Class combinations plus edge cases (empty tool name → Deny, unknown mode → Deny). Allowlist logic tested separately.

  • Error sanitization: formatError at handlers.go:215-217 uses blank identifier _ to drop raw error text entirely. Tests confirm no internal text leaks.

  • ParseWatcherVerdict: Pure function, no I/O (verdict.go:36-53). Three tiers: Silent, Noteworthy, Actionable. Malformed JSON → Noteworthy (tested at verdict_test.go:42-47). Missing block → Noteworthy. Unknown tier → Noteworthy. All paths return nil error.

  • Investigation loop guards: maxToolTurns double-guarded against 0 (config load at model.go:357 rejects v <= 0; runtime guard at investigation.go:67-69 snaps to 6). Timeout enforced via context.WithTimeout at investigation.go:104-105, default 90s. One-at-a-time via watcherAnalyzing flag (safe because Bubble Tea Update is single-threaded).

  • Non-Anthropic degradation: isAnthropicFamily() at investigation.go:153-159 gates tool registration. Non-Anthropic providers get no tools, no errors, one log line (guarded by toolsLoggedOnce at model.go:959). Falls through to synthesis path at watcher.go:253.

  • Config keys: All 5 keys (watcher_max_tool_turns, watcher_investigation_timeout, ai_permission_mode, ai_auto_allow_tools, ai_allowed_command_prefixes) registered in DefaultOptionalKeys with sensible defaults, consumed in resolveInvestigationConfig()/resolveAIPermissionConfig() at model.go:355-383, documented in docs/ai-agents.md and config.go.

  • get_recent_events omission: pkg/delta does not exist. Omission documented in PR body's Deviations section. No get_recent_events tool implementation exists in the codebase.

  • TestRevertCheck_PolicyGate: Proves the ungated path fires handlers, confirming the headline test has teeth for the gating mechanism (though not for the production wiring — see finding #5).


Summary

# Finding Severity Verified by
1 Accept is a no-op — Action never wired must-fix reading production code
2 No key handler for approvals — "press A" inert must-fix grep for Accept/Dismiss/RenderExpanded callers
3 onAsk nil at call site — Ask items never surface must-fix reading watcher.go:250
4 3 fabricated + 2 inaccurate test names in PR body must-fix grep for each test name
5 Production Decide() wiring untested must-fix no test calls policy.Decide() or watcherInvestigateCmd
6 RenderExpanded() is dead code nice-to-have grep for callers

The core infrastructure (registry, gating mechanism, policy engine, verdict parsing, investigation loop bounds) is solid. The gap is in the last mile: the approvals strip renders but cannot be interacted with, and the production wiring of the policy engine to the investigation loop has no test coverage.

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V5: Dead Code and Unused Surface — PR #418

deadcode tool output (full)

pkg/ai/mock.go:18:24: unreachable func: MockProvider.SupportsStreaming
pkg/ai/mock.go:23:6: unreachable func: NewMockProvider
... (6 mock funcs — pre-existing)
pkg/config/config.go:478:6: unreachable func: BuildFullConfig
pkg/config/config.go:705:6: unreachable func: WriteConfigTeams
pkg/config/config.go:730:6: unreachable func: WriteConfigKey
pkg/config/config.go:755:6: unreachable func: WriteConfigMap
pkg/ocm/client.go:174:6: unreachable func: NewClient
pkg/pd/mock.go:96-346: (16 mock funcs — pre-existing)
pkg/tui/chords.go:108:6: unreachable func: chordHelpText
pkg/tui/commands.go:69:20: unreachable func: execErr.Error
pkg/tui/commands.go:80:20: unreachable func: execErr.Code
pkg/tui/commands.go:544:6: unreachable func: AssignedToAnyUsers
pkg/tui/commands.go:557:6: unreachable func: ShouldBeAcknowledged

All 32 unreachable functions are pre-existing on origin/main. This PR adds zero new items to the deadcode list. ✓


New findings from this PR

MUST-FIX

1. Registry.BetaTools() — exported, zero production callers
pkg/ai/tools/registry.go:62BetaTools() returns []anthropic.BetaToolUnionParam and has no production callers. It is only referenced in registry_test.go:53. Production uses GatedBetaTools() (in investigation.go:75) and AsBetaTools() (test-only revert check). This is the exact founding-failure pattern: fully implemented, fully tested, zero callers.

Failure scenario: A developer reads BetaTools() as the primary API and builds code against it, bypassing the policy gate entirely — the very thing this PR exists to prevent.

Verified by: grep -rn '\.BetaTools()' --include='*.go' | grep -v _test.go → empty output.

2. Registry.AsBetaTools() — exported, zero production callers
pkg/ai/tools/registry.go:110AsBetaTools() returns ungated tools. Its only caller is the integration revert-check test (integration_test.go:90). No production code calls it.

Failure scenario: Same as above — an ungated API surface exists with no policy enforcement, inviting accidental bypass.

Verified by: grep -rn '\.AsBetaTools()' --include='*.go' | grep -v _test.go → empty output.

3. Approvals strip: Accept(), Dismiss(), RenderExpanded() — zero production callers, no key handler

  • pkg/tui/approvals.go:56 (Accept) — test-only
  • pkg/tui/approvals.go:68 (Dismiss) — test-only
  • pkg/tui/approvals.go:94 (RenderExpanded) — never called anywhere

The strip renders via Render() in views.go:174, showing "⚑ N asks — press A", but no key handler in tui.go or msgHandlers.go processes the 'A' key for approvals. Asks accumulate but the user can never accept or dismiss them. The Accept method would also be a no-op because the production Ask struct (tui.go:729-733) never sets the Action callback field.

Failure scenario: User sees "⚑ 3 asks — press A" in the TUI, presses A, nothing happens. Asks pile up with no way to clear them.

Verified by: grep -rn '\.Accept\|\.Dismiss\|RenderExpanded' --include='*.go' | grep -v _test.go → zero hits. Searched all key handlers for 'A' or approval dispatch — none found.

NICE-TO-HAVE

4. Config.AllowedCommandPrefixes — struct field populated but never read by Decide()
pkg/ai/policy/policy.go:38 — The field is populated from viper.GetStringSlice("ai_allowed_command_prefixes") at model.go:382, but Decide() never inspects it. The config comment says "unused until phase 415, defined for schema stability." This is fine as documented forward-planning, but worth noting: the field is dead weight today and go vet won't catch it since it's in a public struct.

Verified by: grep -n 'AllowedCommandPrefixes' pkg/ai/policy/policy.go → only the field declaration at line 38, never referenced in Decide().

5. ModeAuto and ModeCustom — identical logic
pkg/ai/policy/policy.go:61-78 — The ModeAuto and ModeCustom branches in Decide() are textually identical (confirmed via diff). There's currently no behavioral distinction between them.

Verified by: diff of the two case branches → identical except the case label.

6. investigationMsg.fullText — struct field set but never read
pkg/tui/investigation.go:27 — The field is populated at investigation.go:134 but the handler at tui.go:708-738 never reads it. The full model response text is computed, stored in the message, and discarded.

Verified by: grep -n 'fullText' pkg/tui/tui.go → zero hits.

7. formatError silently drops the error argument
pkg/ai/tools/handlers.go:215func formatError(msg string, _ error) string discards the error. Every handler call site passes a real err that gets swallowed. The LLM sees "incident not found" with no detail about why.

Read from code, not verified at runtime.

8. AskSuggestedCommand, AskEscalationSuggestion, AskToolPermission — enum values with zero production usage
pkg/tui/approvals.go:17-19 — Three of four AskKind constants exist only in the definition and askKindLabel() switch. Only AskDraftNote is used in production (tui.go:730).

Verified by: grep -rn 'AskSuggestedCommand\|AskEscalationSuggestion\|AskToolPermission' --include='*.go' | grep -v _test.go → only approvals.go definition/switch.

9. ClassWriteLocal, ClassExec, ClassExternal — policy classes with zero production usage
pkg/ai/policy/policy.go:11-13 — All registered tools use ClassRead. The entire non-trivial logic in Decide() (the ModeInteractive/ModeAuto/ModeCustom branches for non-Read classes) is unreachable in the current codebase.

Verified by: grep -rn 'ClassWriteLocal\|ClassExec\|ClassExternal' --include='*.go' | grep -v _test.go | grep -v policy.go → empty.

10. onAsk callback always nil in production
pkg/tui/watcher.go:250 — The only production call to watcherInvestigateCmd passes nil for onAsk. When the gated tool returns Ask from the policy engine, it returns a "waiting for approval" text response to the LLM but never surfaces the ask to the user via the approvals strip. This makes the ModeInteractive Ask path a silent dead end for tool calls.

Verified by: grep -A10 'watcherInvestigateCmd(' pkg/tui/watcher.go → last param is nil.


Summary

Category Count Items
Deadcode tool (pre-existing) ~32 All on origin/main, none added by PR
Exported with zero prod callers (new) 2 BetaTools(), AsBetaTools()
Methods with zero prod callers (new) 3 Accept(), Dismiss(), RenderExpanded()
Struct field set, never read (new) 2 AllowedCommandPrefixes, investigationMsg.fullText
Identical code branches (new) 1 ModeAutoModeCustom
Unreachable by construction (new) 2 Non-Read policy classes, onAsk nil
Enum values never used (new) 3 AskSuggestedCommand, AskEscalationSuggestion, AskToolPermission

Items 4-10 are defensible as forward-planning scaffolding (the plan doc references later phases). Items 1-3 are the classic "unwired code" anti-pattern this project has been burned by before — especially the approvals strip, which renders a UI affordance the user cannot interact with.

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #418 Review — Tools Registry, Policy Engine, Approvals Strip

Headline criterion

PASS. A Deny-everything policy produces zero handler invocations. Verified by running TestHeadline_DenyEverything_ZeroHandlerInvocations and inspecting the gated-tool Execute() path at pkg/ai/tools/registry.go:136-159. The revert-check test (TestRevertCheck_PolicyGate) also proves the gate is load-bearing.

Build: PASS (go build ./... succeeds).


Must-fix

B1. Approvals strip is unwired — asks accumulate without limit, no user action possible

File: pkg/tui/approvals.go:42-49 (Add), pkg/tui/views.go:173 (Render)
Severity: Feature is broken; UX lie; unbounded memory growth

approvalsStrip.Add() is called from the investigationMsg handler (tui.go:729) when an investigation returns an actionable verdict. The strip renders "press A" (approvals.go:85). But:

  1. No key handler is wired for 'A' (or any key) to open the expanded approvals view. The lowercase 'a' is "acknowledge incident" (keymap.go:199).
  2. Accept() and Dismiss() are never called from any production code path — only from tests.
  3. RenderExpanded() is never called from production code.
  4. The asks slice grows without bound. Every actionable investigation appends an Ask. There is no cap, no eviction, and no way for the user to dismiss them.

Failure scenario: User runs srepd with an Anthropic provider for a multi-hour shift. The watcher detects 20 actionable patterns. 20 Asks accumulate in memory with no way to act on them. The strip shows "20 asks — press A" but pressing A acknowledges the highlighted incident instead. The Asks hold closures (Action func() tea.Cmd) that may reference stale model state.

The tests pass because they test the data structure in isolation, not its integration with the TUI. This is exactly the "unwired code" pattern from this project's history.


B2. Investigation goroutine outlives session — context.Background() cannot be cancelled

File: pkg/ai/tools/investigation.go:104
Severity: Goroutine leak on exit

ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout)
defer cancel()

The investigation runs inside a tea.Cmd closure. Bubble Tea's Program.Quit() does NOT cancel outstanding tea.Cmd goroutines — they run until they return. With context.Background(), the only backstop is the 90-second timeout.

Failure scenario: User presses ctrl+c to quit srepd while a watcher investigation is in-flight (making HTTP calls to the Anthropic API). The process appears to hang for up to 90 seconds because the goroutine is blocked inside toolRunner.NextMessage(ctx) waiting for the API. The cancel() is only called when the goroutine finishes — it can't be triggered externally.

This matches the documented bug pattern: "A goroutine still pinned after its caller returned."

Fix: Derive the context from a model-scoped or program-scoped context that is cancelled on quit. The model already has watcherStreamCancel for stream queries — investigation needs the same pattern.


B3. onAsk is nil in runDetectors path — Ask decisions produce a dead-end message with no follow-up

File: pkg/tui/watcher.go:250, pkg/ai/tools/registry.go:143-149
Severity: Silent failure for non-Read tools in future phases

When runDetectors calls watcherInvestigateCmd, it passes nil for onAsk:

cmds = append(cmds, watcherInvestigateCmd(
    ...
    nil, // onAsk
))

When the gated tool's Execute() receives an Ask decision (registry.go:143), it:

  1. Skips the nil onAsk callback (no approval request created)
  2. Returns "Awaiting user approval — this action requires confirmation" as the tool result

The SDK tool runner receives this string as the tool's output and may continue the conversation loop. The model sees "awaiting approval" but no approval mechanism exists in the investigation — the tool will never be re-tried with approval. The investigation burns its remaining tool turns with the model potentially re-requesting the same tool.

Impact for phase 3: None — all 7 registered tools are ClassRead, which gets Allow in every mode except default (unknown mode). This becomes a must-fix when phase 4/5 adds ClassWriteLocal or ClassExec tools.

Downgrading to nice-to-have for this PR since all current tools are read-class. But document it.


Nice-to-have

N1. ModeAuto and ModeCustom are identical in Decide()

File: pkg/ai/policy/policy.go:61-77
Verified by reading: The switch branches for ModeAuto and ModeCustom contain the exact same logic. AllowedCommandPrefixes is declared in the Config struct, populated from viper (model.go:382), documented in config.go, but never read in Decide().

The comment at config.go:70 says "unused until phase 415, defined for schema stability." This is intentional scaffolding — not a bug. But having two enum values with identical behavior is confusing. Consider either:

  • Removing ModeCustom until it diverges, or
  • Adding a comment to the ModeCustom branch explaining what will differ

N2. formatError discards the actual error

File: pkg/ai/tools/handlers.go:215

func formatError(msg string, _ error) string {
    return msg
}

This is intentional (test TestHandler_ErrorDoesNotLeakInternals validates it), but it makes debugging harder. The error is already logged at the call site via log.Warn in some paths but not in the handler. Consider logging the error inside formatError before discarding it.

N3. Truncate() panics on maxBytes < len(marker)

File: pkg/ai/tools/registry.go:184-190
Verified by running: Truncate("hello world", 5) panics with slice bounds out of range [:-7]. Production always passes MaxResponseBytes = 8192, so this can't trigger today. But the function is exported and accepts int — a defensive if maxBytes < len(marker)+1 { return s[:maxBytes] } guard prevents a future surprise.

N4. investigation_test.go only tests helpers, not the investigation loop

File: pkg/tui/investigation_test.go
The test file covers isAnthropicFamily and defaultInvestigationConfig but NOT watcherInvestigateCmd. The core investigation loop — which does API calls, tool execution, verdict parsing, and error handling — has zero test coverage. A mock ToolRunnerFactory would be straightforward.

This is the "unwired code" pattern: the test proves the helpers work, but a complete stub of watcherInvestigateCmd would not break any test.

N5. AsBetaTools() exports an ungated path

File: pkg/ai/tools/registry.go:110
AsBetaTools() returns tools that execute without any policy check. It's not called from production code today (only from TestRevertCheck_PolicyGate), but it's exported. Any future caller gets policy bypass. Consider making it unexported (asBetaTools) or adding a build-tag restriction.


Clean

  • Concurrency in the Update loop: The watcherAnalyzing guard is correctly single-threaded (all reads/writes in the Update loop). No data race.
  • Registry mutex: sync.RWMutex correctly protects concurrent access to the tools slice. GatedBetaTools snapshots by value under RLock.
  • Policy engine purity: Decide() is pure — no I/O, no side effects, no global state. Exhaustive test coverage across all modes and classes.
  • Handler error paths: All handlers return (string, nil) on API errors via formatError, preventing the SDK tool runner from seeing Go errors for expected conditions (not-found, invalid input). marshalResult can return a real error on marshal failure, which correctly propagates.
  • Tool registration: Duplicate names are rejected. Insertion order is preserved. Tools() returns a copy.
  • Verdict parser: Gracefully defaults to TierNoteworthy on malformed/missing JSON blocks.
  • Race detector: All tests pass with -race — no data races detected (pkg/ai: 1.3s, pkg/ai/policy: 1.0s, pkg/ai/tools: 1.0s, pkg/tui: 93.0s).

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #418 Code Review — Tools Registry, Policy Engine, Approvals Strip

Summary

This is well-structured, idiomatic Go. The layering is clean (pure policy engine → registry → gated tools → TUI wiring), the headline test has genuine teeth (verified by the revert-check test), and the exported surface is minimal. Seven read-class tools, a pure Decide() function, and a TUI approvals strip — nothing over-engineered.

Below are findings ranked by severity. Where I ran something to verify, I say so.


Must-fix

1. formatError discards the error — LLM gets no diagnostic signal
pkg/ai/tools/handlers.go:215

func formatError(msg string, _ error) string {
    return msg
}

Every handler calls formatError("incident not found", err) but the error is silently dropped. The LLM sees only "incident not found" — no hint whether the cause was a network timeout, auth failure, or a genuinely missing ID. The plan doc (line 48-51) says this is deliberate to prevent token leaks, which is a valid posture for detailed stack traces, but the err itself is usually just "not found" or "context deadline exceeded" — one-line strings the LLM could use to decide whether to retry.

Failure scenario: OCM returns a transient auth error. The LLM sees "cluster not found" and tells the user the cluster doesn't exist, when in fact it's an auth problem that would succeed on retry. The SRE acts on wrong information.

Recommendation: Include the error class (timeout, auth, not-found) without the full message: return fmt.Sprintf("%s: %s", msg, classifyErr(err)). Or at minimum, log the full error (it's currently lost entirely).

Verified by reading: confirmed _ error is unused and no log call captures it.


2. ModeAuto and ModeCustom are identical — dead code path
pkg/ai/policy/policy.go:61-78

The case ModeAuto and case ModeCustom branches in Decide() have byte-identical logic. AllowedCommandPrefixes is declared in Config (line 38) and populated from config (model.go:382) but never read by Decide(). If ModeCustom exists to eventually use command prefixes, it should be documented as a placeholder or removed.

Failure scenario: A user sets ai_permission_mode: custom expecting differentiated behavior from auto. They get identical behavior — no indication anything is different. When command-prefix enforcement lands later, its absence now is invisible.

Verified by grep: AllowedCommandPrefixes has exactly 2 references — the struct field and the viper read. Zero reads in Decide().


3. Truncate panics on small maxBytes
pkg/ai/tools/registry.go:184-190

func Truncate(s string, maxBytes int) string {
    if len(s) <= maxBytes {
        return s
    }
    const marker = "\n[truncated]"
    return s[:maxBytes-len(marker)] + marker  // panics if maxBytes < 12
}

Truncate is exported. If called with maxBytes < len(marker) (12), the slice expression goes negative and panics. With MaxResponseBytes = 8192 this can't happen today, but the function is public API. Per CONVENTIONS.md: "never panic in library code."

Failure scenario: A future caller passes a small max (e.g., for a summary field). Runtime panic in the TUI.

Verified by reading: no bounds guard exists.


Nice-to-have

4. RenderExpanded has zero callers — dead code
pkg/tui/approvals.go:94

RenderExpanded is defined but never called anywhere in the codebase. View() only calls Render() (the collapsed strip). If the expanded panel is planned for a future PR, a comment or TODO would help; otherwise it's dead weight.

Verified by grep: exactly 1 hit (the definition itself).


5. watcherInvestigateCmd and initToolRegistryForModel have no tests

The investigation loop (investigation.go:49-137) is the most complex new function: it builds gated tools, constructs SDK params, runs NextMessage in a loop, and parses the verdict. It has zero test coverage — only isAnthropicFamily and defaultInvestigationConfig are tested. The initToolRegistryForModel wiring function is also untested.

This falls into the repo's "unwired code" failure pattern: watcherInvestigateCmd compiles and is called from watcher.go:242, but no test exercises the path from observation → investigation → verdict → TUI state. A bug in the NextMessage loop or context wiring would be invisible.

Failure scenario: A future SDK upgrade changes BetaToolRunner behavior. All tool/policy unit tests pass. The investigation loop silently breaks.

Verified by grep: zero matches for watcherInvestigateCmd or investigationMsg in any *_test.go file.


6. extractToolRunnerFactory uses untyped interface assertion
pkg/tui/investigation.go:141-149

func extractToolRunnerFactory(provider interface{}) ToolRunnerFactory {
    type betaMessagesProvider interface {
        BetaMessages() *anthropic.BetaMessageService
    }
    ...
}

The provider interface{} parameter loses type information. If ai.Provider grew a BetaMessages() method on the interface, this local type assertion would become unnecessary. As-is, it works correctly via structural typing, but using any for a parameter that's always ai.Provider is a mild idiom deviation.

Cosmetic — no runtime impact.


7. Decide takes json.RawMessage it never uses
pkg/ai/policy/policy.go:43

func Decide(cfg Config, toolName string, class Class, _ json.RawMessage) Decision {

The 4th parameter is explicitly ignored (_). If it's reserved for future input-based policy (e.g., argument validation), a comment would help. Otherwise, it widens the API surface for no reason.

Cosmetic — the _ name prevents accidental use.


Clean

  • gofmt: clean. Zero files flagged by gofmt -s -l cmd pkg.
  • go vet: clean. Zero issues.
  • golangci-lint: not available in this environment; recommend running locally.
  • No new //nolint directives.
  • No panics in library code (except the Truncate edge case above).
  • No new dependencies: the only go.mod change is bumping anthropic-sdk-go from v1.57.0 → v1.61.0. Same module path, same owner — no supply-chain concern.
  • Error wrapping with %w: used correctly in marshalResult (handlers.go:210), investigation.go:62.
  • context.Context first param: respected in all handlers and Execute methods.
  • Comma-ok type assertions: used correctly in extractToolRunnerFactory (line 146) and throughout.
  • Receiver naming: consistent single-letter receivers (r, g, u, a) matching type initials.
  • Interface placement: ToolRunnerFactory is in the consumer package (pkg/tui), not the provider — correct Go convention.
  • Zero-value usefulness: Registry requires NewRegistry() (needs initialized map), which is appropriate.
  • Headline criterion verified: TestHeadline_DenyEverything_ZeroHandlerInvocations passes, and TestRevertCheck_PolicyGate proves the test has teeth by verifying the ungated path does fire handlers.
  • Test quality: policy tests are exhaustive (every mode × class combination). Registry tests cover duplication, copy safety, and SDK shape. Handler tests cover happy path, error path, and truncation.
  • Plan document: present at docs/plans/413-tools-policy-approvals.md, thorough.

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review: PR #418 — Tool Registry, Policy Engine, Approvals Strip

Reviewer: automated security analysis
Branch: srepd/ai-p3-tools-policy
Scope: policy gate integrity, data leakage, approvals consent, injection, resource exhaustion, test faithfulness, fixtures


MUST-FIX

1. Fail-open default case in policy gate — unknown Decision values execute the handler

pkg/ai/tools/registry.go:138-150

The gatedTool.Execute switch matches Deny and Ask explicitly, then falls through to default which executes the handler. Allow is the only other value today, so default acts as case Allow. But Decision is an int — if a fourth value is ever added (e.g., Audit, RateLimit), it will silently execute the handler because the new value won't match Deny or Ask.

Failure scenario: A future PR adds policy.Log = 3. Any tool call that returns Log hits default, the handler fires, and the new policy intent is silently bypassed.

Verified by running: Passed Decision(42) to a gated tool — handler executed, zero errors returned.

Fix: Switch to explicit case policy.Allow: with default: Deny.


2. Truncate panics on small maxBytes values

pkg/ai/tools/registry.go:189

Truncate(s, maxBytes) computes s[:maxBytes-len(marker)]. When maxBytes < len(marker) (12 bytes), the slice index goes negative and panics with slice bounds out of range.

Failure scenario: Truncate("anything", 5) → panic, process crash.

Verified by running: Called Truncate("very long string", 5) — got panic: runtime error: slice bounds out of range [:-7].

Truncate is exported and maxBytes is caller-controlled. While the production constant is 8192, any future caller or test using a smaller value triggers a crash.


3. nil decide function panics in gatedTool.Execute

pkg/ai/tools/registry.go:137

GatedBetaTools(decide, onAsk) stores decide without nil-checking. Every Execute call dereferences it: g.decide(g.tool.Name, ...). A nil decide panics.

Failure scenario: Caller passes nil as the decide function (e.g., a test or a wiring mistake) → nil pointer dereference → process crash.

Verified by running: Called GatedBetaTools(nil, nil) then Execute — got PANIC: runtime error: invalid memory address or nil pointer dereference.

Contrast: onAsk has a nil guard at line 144 (if g.onAsk != nil). decide has no such guard.

Fix: Either validate at construction time (GatedBetaTools returns error if decide is nil) or add a nil guard that defaults to Deny.


4. Investigation loop logs raw SDK errors at Warn level — breaks plan-101 token-leak property

pkg/tui/investigation.go:111 and pkg/tui/tui.go:711

The investigation error path logs err at log.Warn twice. The Anthropic SDK's Error.Error() includes the full API JSON response body (r.JSON.raw). This appears in standard log output.

The existing watcher query path intentionally keeps raw errors at log.Debug (see pkg/tui/commands.go:289-293, which has a comment explaining why) and uses ClassifyProviderError for the Warn-level message. The investigation path does not follow this pattern.

Failure scenario: An API error response body containing internal details (error messages, request IDs, model identifiers) appears in standard Warn-level logs. Not user-visible in the TUI, but present in log files without debug mode.

Verified by reading: Confirmed ClassifyProviderError is never called in the investigation error path.


5. investigation_test.go tests only two trivial helpers — plan claims four substantial test scenarios that don't exist

pkg/tui/investigation_test.go (33 lines total)

The plan document (docs/plans/413-tools-policy-approvals.md, lines 288-294) claims:

  • "Bounded by max tool turns" — no test
  • "Bounded by timeout" — no test
  • "Policy deny stops tool execution" — no test
  • "Verdict extracted from model response" — no test

The actual file tests only isAnthropicFamily() (a 7-case string comparison) and defaultInvestigationConfig() (checks that defaults are non-zero). Neither exercises the investigation loop, the SDK integration, the timeout, or the policy enforcement during investigation.

This is the "unwired code" pattern from the project's recurring failure table. The investigation loop (watcherInvestigateCmd) has no test coverage at all. A test that stubs ToolRunnerFactory and verifies that a deny-all policy produces zero handler invocations during investigation does not exist — the headline test only covers the registry-level gate, not the investigation-loop-level integration.

Verified by reading: grep -rn 'watcherInvestigateCmd' pkg/tui/*_test.go returns zero results.


NICE-TO-HAVE

6. onAsk passed as nil in production — Ask decisions silently swallowed

pkg/tui/watcher.go:250

runDetectors calls watcherInvestigateCmd(..., nil) for the onAsk parameter. When the policy engine returns Ask, the gated tool checks if g.onAsk != nil (it's nil), skips the callback, and returns "Awaiting user approval" to the LLM. The TUI is never notified. No Ask appears in the approvals strip.

Currently harmless because all registered tools are ClassRead (which always returns Allow), so the Ask path is unreachable. But if a write-class tool is added without fixing this wiring, the user won't see the approval request.

Verified by reading: All 7 tools in handlers.go have Class: policy.ClassRead.

7. Actionable verdict creates an Ask with nil Action — Accept is a no-op

pkg/tui/tui.go:729-733

The TierActionable handler creates an Ask with Title and Body but no Action callback. Accept(idx) at approvals.go:62 checks ask.Action != nil before calling, so Accept simply removes the Ask silently. The user sees the approval strip and the "[Enter] Accept" hint, but pressing it does nothing.

Combined with finding 6 (onAsk nil), the entire approvals infrastructure is display-only scaffolding: Asks from verdicts display in the collapsed strip but can never be accepted or dismissed (no key handlers are wired in msgHandlers.go).

Security-positive (no LLM-authored action can cause a write), but functionally incomplete.

8. list_queue has no result-count cap — transient memory spike before truncation

pkg/ai/tools/handlers.go:119-142

get_service_logs caps results at maxServiceLogs = 5 before marshalling. list_queue, get_alerts, get_notes, and get_limited_support marshal the full API response into a string. The Truncate call afterwards caps the output at 8192 bytes, but the full string is allocated first.

Worst case for list_queue: PagerDuty returns up to 100 incidents per page, each several KB → ~100-400 KB transient allocation. Not a practical DoS but inconsistent with the get_service_logs pattern.

9. LLM-produced verdict strings rendered without sanitization

pkg/tui/tui.go:725,735

verdict.Summary is passed to startTypewriter and rendered into the watcher viewport. verdict.Action becomes Ask.Body (currently unreachable via RenderExpanded). Neither is filtered for ANSI escape sequences.

A compromised or jailbroken LLM could produce ANSI sequences to manipulate the terminal display (clear screen, change title, overwrite text). Bubble Tea's viewport does not strip escape sequences. Low practical risk since the LLM is the project's own watcher, not external input.


CLEAN CATEGORIES

  • Injection (shell/URL/file path): Clean. No tool input reaches os/exec, os.Open, filepath, or net/http. All tools delegate to existing PD/OCM client interfaces.
  • SSRF / path traversal: Clean. Tool inputs are PagerDuty IDs and OCM cluster IDs passed to typed client methods, not constructed URLs or file paths.
  • Fixtures: Clean. No real cluster IDs, domains, tokens, or unmarked UUIDs in the new files. Existing openshiftapps.com/devshift.net references in pre-existing test fixtures are not part of this PR.
  • formatError: Clean. The _ error parameter is discarded at all 9 call sites. No handler path returns raw errors to the model.
  • Concurrent investigations: Clean. watcherAnalyzing boolean in the single-threaded Bubble Tea Update loop prevents parallel investigations.
  • ClassifyProviderError property for non-investigation paths: Preserved. Existing watcher query path correctly uses ClassifyProviderError.
  • Case-sensitivity / unregistered tool names: Clean. The SDK uses exact-match map lookup; unregistered names return "Tool not found" without reaching the gate.
  • Post-snapshot tool registration: Clean. GatedBetaTools copies tools by value; later registrations are not reachable through the gated slice.

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #418 — V6: Unit Test Adequacy Review

Mutation Testing Matrix

Every mutation below was applied to production code, compiled, tested, and reverted.

# Mutation Result Catching Test
1 Bypass policy.Deny branch in gatedTool.Execute — Deny case calls handler instead of returning denial message CAUGHT TestHeadline_DenyEverything_ZeroHandlerInvocations (integration_test.go:19)
2 Make Decide always return Allow (early return before switch) CAUGHT TestDecide_Exhaustive — 11 sub-cases fail (policy_test.go:10)
3 Remove truncation cap — Truncate returns input unchanged CAUGHT TestHandler_TruncationMarker (handlers_test.go:201)
4 Make ParseWatcherVerdict always return TierNoteworthy — skip JSON parsing CAUGHT TestParseWatcherVerdict_ValidTiers — all 5 sub-cases fail (verdict_test.go:10)
5 Make registry accept duplicate names — skip duplicate check CAUGHT TestRegistry_DuplicateRejection (registry_test.go:14)
6 Break non-Anthropic degradation — isAnthropicFamily always returns true CAUGHT TestIsAnthropicFamily — 4 sub-cases fail (investigation_test.go:9)
7 Make Accept a no-op — remove Action invocation CAUGHT TestApprovalsStrip_AcceptInvokesAction (approvals_test.go:23)
8 Break gatedTool.Execute Allow path — make default case return denial instead of calling handler NOT CAUGHT No test exercises GatedBetaTools with an Allow decision

Must-Fix

1. gatedTool.Execute Allow path is untested (registry.go:150-158)

Verified by running. Replacing the entire default (Allow) branch of gatedTool.Execute with a denial message — all 34 tests pass. The test suite exercises Deny (TestHeadline) and Ask (TestGatedBetaTools_AskCallsCallback) but never exercises GatedBetaTools with a decide function that returns Allow. TestRevertCheck_PolicyGate uses AsBetaTools() (the ungated path), not GatedBetaTools(), so it doesn't cover this.

Failure scenario: A refactor could break the Allow path (e.g., forget to call the handler, double-truncate, swallow the error) and every test would still pass. This is the path that runs in production when the policy engine approves a tool call.

Fix: Add a test like TestGatedBetaTools_AllowExecutesHandler that registers a tool, wraps it with GatedBetaTools using a decide function that returns policy.Allow, calls Execute, and asserts the handler output is returned (not a denial message) and that the handler was invoked exactly once.

Nice-to-Have

2. TestApprovalsStrip_RenderShowsCount doesn't verify the count (approvals_test.go:65)

Verified by running. Hardcoding 999 in the Render format string — test still passes. The test only checks NotEmpty and Width > 0, not that the count matches. This is cosmetic (the feature is "render something"), not a behavioral gap.

Fix: Add assert.Contains(t, rendered, "2 asks").

3. ModeCustom missing ClassExec and ClassExternal in TestDecide_Exhaustive (policy_test.go)

Suspected from reading. The exhaustive table covers ModeCustom with ClassRead, ClassWriteLocal/NotListed, and ClassWriteLocal/Listed, but skips ClassExec and ClassExternal. Currently ModeCustom and ModeAuto share the same switch body so these are effectively tested via ModeAuto, but if they diverge later this gap will matter.

4. extractToolRunnerFactory and watcherInvestigateCmd have no unit tests (investigation.go)

Suspected from reading. These are the functions that wire GatedBetaTools into the real investigation loop. extractToolRunnerFactory does an interface type assertion; watcherInvestigateCmd orchestrates the SDK BetaToolRunner. Both have production callers (model.go:966, watcher.go:242). Testing them requires a mock ToolRunnerFactory, which is significant integration work — acceptable for phase 3 but should be tracked.

Coverage

Package Coverage
pkg/ai/policy 100.0%
pkg/ai/tools 80.3%
Combined 83.0%

The 80.3% in pkg/ai/tools is partially the gatedTool Allow path (finding #1 above) and partially accessor methods (Name/Description/InputSchema on ungatedTool and gatedTool) which are trivial.

Other Checks

  • make test-race: All new package tests pass with -race. No data races detected.
  • Flakiness: No time.Sleep, wall-clock assertions, or real network calls in any test. The watcherDedup uses time.Now() in production but is not exercised in the tests under review.
  • Table-driven completeness for Decide: 20 cases covering all 4 modes x 4 classes (with allowlist variants for Auto/Custom), plus 2 edge cases (empty tool name, unknown mode). The only gap is ModeCustom missing ClassExec/ClassExternal (nice-to-have #3 above).
  • Confounded assertions: TestGetAlerts_HappyPath, TestGetNotes_HappyPath, and TestListQueue_HappyPath assert only NotEmpty — they'd pass if the handler returned any non-empty string. However, the mock returns real-shaped data so this is low risk.
  • TestRevertCheck_PolicyGate is well-designed: It explicitly proves the headline test has teeth by showing the ungated path fires the handler. Good anti-tautology test.

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V7 — Documentation Accuracy & Completeness Review

Must-fix

1. Privacy sections omit new data types sent to LLM via tool results

Files: docs/ai-agents.md:249, docs/llm-providers.md:335-341

What's wrong: Both privacy sections list only the data sent in the pre-413 watcher context (incident title/ID/status, service name, alert names, cluster IDs). With tool-assisted investigation, the tool handlers now marshalResult() full objects back to the Anthropic API, including:

  • Note content (actual text of PagerDuty notes, not just "notes exist")
  • Service log entries (with severity and full summary text)
  • Limited support reasons (summaries of why a cluster is in limited support)
  • Full incident queue (up to 25 incidents, marshalled as JSON)

These are sent as tool results to the LLM provider over the network. The privacy note doesn't mention this.

Failure scenario: A user reads the privacy section, sees only "titles, service names, alert names, cluster IDs," and concludes that note content and service log bodies stay on their machine. They configure an Anthropic provider for a cluster handling sensitive workloads. The tool investigation loop sends full note text and service logs to the Anthropic API — data the user didn't consent to sending based on the documented scope.

Verified by: reading pkg/ai/tools/handlers.go — every handler calls marshalResult() on the full API response object, which is returned as a tool result to the SDK's BetaToolRunner, which sends it to the Anthropic Messages API.

2. Plan doc "Solution" section describes AsBetaTools() as if it still exists

File: docs/plans/413-tools-policy-approvals.md:52-54, 58, 158

What's wrong: The plan's Solution section says:

"Two exposure modes. AsBetaTools() returns all tools ungated ... GatedBetaTools() checks policy before executing each handler."

And line 58:

"Registry struct with Register, AsBetaTools, GatedBetaTools, and Execute methods."

And Testing section line 158:

"AsBetaTools returns correct anthropic.BetaTool values."

But AsBetaTools() was deleted in this PR (confirmed: grep -rn AsBetaTools pkg/ai/tools/ returns nothing). The post-mortem at line 276 correctly describes the deletion, but the design and testing sections still describe the method as shipped code.

Failure scenario: A future developer reads the plan to understand the tool registry's API, calls AsBetaTools(), gets a compile error, and is confused about the actual API surface. Or worse, re-implements it without understanding why it was removed (policy bypass risk documented in the post-mortem).

Verified by: grep -rn AsBetaTools pkg/ai/tools/ — zero results.

3. PR body cites non-existent test name TestGetIncident_ErrorResponse

File: PR body, "Criterion -> Test traceability" table

What's wrong: The traceability table says:

Error sanitization (token-leak) | TestGetIncident_ErrorResponse | pkg/ai/tools/handlers_test.go

The actual test function is TestGetIncident_NotFound. There is no function named TestGetIncident_ErrorResponse anywhere in the codebase.

Failure scenario: A reviewer tries to run go test -run TestGetIncident_ErrorResponse to verify the token-leak property and gets zero test matches, undermining confidence in the claimed test coverage.

Verified by: grep -rn "func TestGetIncident_ErrorResponse" pkg/ — zero results. Actual test: TestGetIncident_NotFound at pkg/ai/tools/handlers_test.go:27.

4. PR body missing ## Testing this live section

File: PR body

What's wrong: The PR template requires a ## Testing this live section that must mention --dev. The PR body has ## Test plan and ## Visual validation but no ## Testing this live section.

Failure scenario: Reviewers cannot determine how to manually validate the feature in a running srepd instance. The --dev flag (which uses mock PD data) is the standard way to test without a live PagerDuty account.

Verified by: reading the full PR body — the section is absent.

Nice-to-have

5. auto and custom mode descriptions in ai-agents.md are incomplete

File: docs/ai-agents.md:128-129

What's wrong: The mode table says:

  • auto: "Tools on the ai_auto_allow_tools allowlist execute without prompting"
  • custom: "Fully user-defined allowlist via ai_auto_allow_tools"

Both omit that Read-class tools are always auto-allowed regardless of the allowlist (same as every other mode), and that non-allowlisted non-Read tools get Ask (not Deny). A reader could interpret auto as "only allowlisted tools work" and custom as "only what's on the list runs."

In code (pkg/ai/policy/policy.go:61-78), both ModeAuto and ModeCustom do: ClassRead→Allow, in-allowlist→Allow, else→Ask.

The description for plan correctly says "Read-only tools only" (implying reads are special), but auto/custom don't carry this qualifier forward.

Failure scenario: A user sets ai_permission_mode: custom with an empty allowlist expecting all tools to be blocked. Instead, all 7 read-class tools execute without prompting because ClassRead is always allowed. The current phase has only read tools, so the empty-allowlist user gets the same behavior as plan/interactive/auto — no actual restriction.

Verified by: reading pkg/ai/policy/policy.go:61-78.

6. Plan doc Testing section claims AsBetaTools test coverage that no longer exists

File: docs/plans/413-tools-policy-approvals.md:157-158

What's wrong: The Testing section's "Tool registry" subsection claims:

"AsBetaTools returns correct anthropic.BetaTool values."

This test was presumably renamed or removed when AsBetaTools() was deleted. A plan doc that claims test coverage for deleted code misleads anyone doing a test-coverage audit.

Verified by: grep -rn "AsBetaTools" pkg/ai/tools/registry_test.go — zero results.

Clean categories

  • README.md: A keybinding added to the key table (line 235). Matches keymap.go:260-261. Clean.
  • docs/quickstart.md: make quickstart-check and make quickstart-verify both pass. Clean.
  • Config defaults: All 5 new config keys documented with correct defaults matching pkg/config/config.go:43-46. Clean.
  • docs/llm-providers.md tool support table: Present with correct per-provider breakdown matching isAnthropicFamily() at pkg/tui/investigation.go:177-183. Clean.
  • Plan doc: Present at docs/plans/413-tools-policy-approvals.md, no UNVERIFIED items, post-mortem section included. Clean (aside from stale AsBetaTools references noted above).
  • All cited test names exist except TestGetIncident_ErrorResponse (finding #3 above). 27 of 28 verified.
  • Timeouts table in ai-agents.md:237-243: Watcher tool investigation correctly documented as 90s configurable. Matches defaultInvestigationConfig() at investigation.go:49. Clean.
  • Health check table: Anthropic correctly listed as "(no health check)". Clean.

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #418 — Test Adequacy Review (V6: Mutation Testing)

Mutation Testing Matrix

Every mutation was compiled (no build-break cheats), run against the test suite, then reverted.

# Mutation File:Line Test(s) That Caught It Result
1 Bypass policy.Deny branch — make Deny run handler registry.go:102 TestHeadline_DenyEverything_ZeroHandlerInvocations CAUGHT
2 Decide always returns Allow policy.go:43 TestDecide_Exhaustive (12 subtests failed) CAUGHT
3 Remove truncation cap (Truncate returns input unchanged) registry.go:147 TestHandler_TruncationMarker, TestTruncate_SmallMaxBytes_NoPanic, TestGatedBetaTools_AllowPath_Truncation CAUGHT
4 ParseWatcherVerdict always returns TierNoteworthy verdict.go:36 TestParseWatcherVerdict_ValidTiers (silent, actionable subtests) CAUGHT
5 Registry accepts duplicate names registry.go:44 TestRegistry_DuplicateRejection CAUGHT
6 isAnthropicFamily always returns true investigation.go:177 TestIsAnthropicFamily (ollama, openai, ramalama, empty) CAUGHT
7 Accept skips Action invocation (DraftNote becomes no-op) approvals.go:57 TestApprovalsStrip_AcceptInvokesAction, TestApprovalsStrip_DraftNoteAcceptInvokesPDMock, TestApprovalsStrip_AcceptSelected CAUGHT
(bonus) Remove service-log maxServiceLogs cap handlers.go:186 TestGetServiceLogs_Truncation CAUGHT

All 8 mutations caught. No gaps in the critical-path coverage.

Coverage

Package Coverage
pkg/ai/policy 100%
pkg/ai/tools 83.8%
pkg/tui (new files only) ~77.6% (overall); approvals.go ~95%, watcher.go ~97%, investigation.go ~73%

Uncovered in pkg/ai/tools: gatedTool.Name(), .Description(), .InputSchema() (0% each) — these are SDK interface methods exercised only by the real Anthropic runner, not unit-testable in isolation. Not a concern.

Race Detection

go test -race ./pkg/ai/... ./pkg/tui/...clean, no data races detected.

Findings

Nice-to-have (not must-fix)

1. Decide table: ModeCustom missing ClassExec and ClassExternal cases
policy_test.go:41-43: ModeCustom is tested only for ClassRead and ClassWriteLocal. ClassExec and ClassExternal are not explicitly tested. Since ModeCustom is currently identical to ModeAuto (documented, and plan 415 will differentiate), the mutation was still caught via other mode tests. However, when plan 415 differentiates ModeCustom, missing rows could let a regression slip.
Verified by reading the test table; not a correctness bug today.

2. watcherSynthesizeCmd at 0% coverage
commands.go:331: The non-Anthropic synthesis fallback path is never exercised by any test. The watcherSynthesisMsg handler is tested (confirmed by mutation 6's catch via isAnthropicFamily), but the command that produces that message is not — the mock watcher tests route through the tool-using investigation path or direct message injection.
Verified by reading go tool cover output. Not a correctness bug because the message handler IS tested, but the command itself could silently break.

3. extractToolRunnerFactory at 0% coverage
investigation.go:165: This glue function (type-asserts a provider to get BetaMessages()) has no test. It's a thin adapter and unlikely to break, but a nil-return path could mask a misconfigured provider at runtime.
Verified by reading coverage output.

4. approvalsStrip.Selected() at 0% coverage
approvals.go:94: The Selected() getter is never called in tests — tests access strip.selected directly (internal field). Not a correctness risk since the field IS tested, but the public API surface has a gap.
Verified by reading coverage output.

Clean areas (no findings)

  • Headline criterion: The TestHeadline_DenyEverything_ZeroHandlerInvocations + TestRevertCheck_PolicyGate pair is exemplary — one proves the gate blocks, the other proves the gate is load-bearing. Mutation 1 confirmed this.
  • Flakiness: No time.Sleep, no real network calls, no wall-clock assertions. The time.Now() uses in watcher tests set model fields for render assertions — deterministic.
  • Verdict parsing: Full tier coverage (silent, noteworthy, actionable, unknown, malformed, empty, extra keys). Mutation-proof.
  • Approvals strip: Thorough — add/remove/accept/dismiss/bounds/selection/rendering all tested with action-invocation verification. Mutation 7 confirmed this.
  • Policy engine: 100% statement coverage with exhaustive Mode x Class matrix (minor ModeCustom gap noted above). Mutation-proof.
  • Registry: Duplicate rejection, copy semantics, BetaTools shape, gated tool paths (Deny/Ask/Allow) all tested through both unit and integration tests.

Summary

The tests are strong. Every critical mutation was caught, coverage is good, and there's no flakiness. The four nice-to-haves are all about completeness of secondary paths — none is a correctness gap today.

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #418 — Adversarial Re-Review After Fix Rounds

Reviewer: Claude Code (automated adversarial review)
Branch: srepd/ai-p3-tools-policy
Scope: a381d12..HEAD (10 fix-round commits) + full feature coherence
Date: 2026-08-01


Verification Summary

Check Result
go build ./... PASS
go test -race ./pkg/ai/... ./pkg/tui/... PASS
deadcode ./... No new entries (all pre-existing: mocks, config helpers)
make test-all 1 pre-existing failure in cmd/ (unrelated: /var/log read-only in sandbox) — no regressions from this PR
All test names from PR body exist in source PASS
AsBetaTools removed Confirmed (zero grep hits)

Fix Verification — All Seven Fixes Hold

  1. Approvals strip wired end to endA key binding in keymap.go:259-262, handler in msgHandlers.go:212-222, expanded/collapsed rendering in views.go:173-180, switchApprovalsFocusMode with Accept/Dismiss/navigation. Holds.
  2. Headline test runs production pathTestWatcherInvestigateCmd_DenyEverything_ProductionPath uses real policy.Decide via investigationConfig, not a hand-rolled closure. Holds.
  3. Allow path testedTestGatedBetaTools_AllowPath_HandlerRuns and TestWatcherInvestigateCmd_AllowPath_HandlerRuns both assert handler invocation count > 0. TestRevertCheck_PolicyGate proves the deny test has teeth. Holds.
  4. Raw SDK errors moved to Debuginvestigation.go:130 uses log.Debug for raw error, log.Warn for classified error. tui.go:711-712 same pattern. User-facing paths (errMsg) use ClassifyProviderError which extracts only the message field. Holds.
  5. Truncate panic guardregistry.go:152-156 handles maxBytes <= 0 and maxBytes <= len(marker). TestTruncate_SmallMaxBytes_NoPanic covers 0, 1, and 5. Holds.
  6. AsBetaTools removed — zero grep hits across the repo. Holds.
  7. formatError conveys error classhandlers.go:222-228 uses classifyToolError to return class labels ("timeout", "auth error", "not found", "network error", "request failed") instead of raw error strings. TestHandler_FormatErrorIncludesClass verifies. Holds.

Must-Fix: Mutation-Surviving Test Gaps

M1. investigationMsg handler discards ask without test detection

File: pkg/tui/tui.go:729-732
Failure scenario: Remove m.approvals.Add(ask) at line 731 (replace with _ = m.buildAskFromVerdict(msg.verdict)). The code compiles. All tests pass. An LLM-authored actionable verdict never reaches the approvals strip — the user sees the typewriter summary but has no Accept button. PagerDuty writes (notes, re-escalations) silently stop working through the watcher path.
Verified by running: Mutation applied, go test ./pkg/tui/... -run 'TestBuildAsk|TestApprovals' -count=1 passed, mutation reverted.
Severity: HIGH — this is the only path from AI verdict to PD write. The ask_wiring_test.go tests validate buildAskFromVerdict in isolation but no test exercises the investigationMsg handler in Update() that actually adds the ask to the strip.
Fix: Add an integration test that sends an investigationMsg{verdict: actionable} through model.Update() and asserts m.approvals.Count() > 0.

M2. switchApprovalsFocusMode Enter handler discards cmd without test detection

File: pkg/tui/msgHandlers.go:1121-1126
Failure scenario: Change return m, cmd to return m, nil at line 1126. The code compiles. All tests pass. User presses Enter on an approval → the ask is removed from the strip (Accept still fires internally) but the returned tea.Cmd — which is what actually posts the PD note or triggers re-escalation — is silently dropped. The UI shows "accepted" but nothing happens.
Verified by running: Mutation applied, all approvals tests passed, mutation reverted.
Severity: HIGH — this is the final gate between user Accept and PD write. approvals_test.go tests Accept() in isolation (verifying the callback fires) but no test drives the Enter key through switchApprovalsFocusMode and asserts the returned cmd is non-nil.
Fix: Add a test that creates a model with approvalsExpanded=true, an ask with a non-nil Action that returns a non-nil cmd, sends an Enter tea.KeyMsg through keyMsgHandler, and asserts the returned tea.Cmd is non-nil.


Nice-to-Have

N1. Approvals queue is unbounded

File: pkg/tui/approvals.go:43-51
Failure scenario: A malfunctioning or adversarial LLM returns actionable verdicts on every watcher cycle (every 15 seconds). Each adds an Ask via approvals.Add(). Over hours, the asks slice grows without bound. Memory impact is modest per-ask but accumulates.
Severity: LOW — bounded in practice by MaxIterations (6 tool turns), investigation timeout (90s), and the watcher poll interval. An explicit cap (e.g., 50 asks, dropping oldest) would be belt-and-suspenders.

N2. classifyToolError fallback returns "request failed" — consider "unknown error"

File: pkg/ai/tools/handlers.go:243
Severity: COSMETIC — "request failed" is slightly misleading for non-request errors (e.g., JSON marshal failure). "unknown error" would be more honest. Not a correctness issue.


Clean Categories

  • Policy gate correctness: Verified by mutation (TestRevertCheck_PolicyGate), by deny-all test, and by reading gatedTool.Execute. The default: branch (Allow path) runs the handler. Case-sensitivity is handled by exact string match in isInList. Unregistered tool names cannot reach a handler — GatedBetaTools iterates only registered tools, and the SDK only calls tools it received in the tools list.
  • SuggestedCommand exec safety: copyToClipboardCmd returns a setStatusMsg — no exec.Command, no clipboard write, no shell invocation. Verified by TestBuildAskFromVerdict_SuggestedCommand_CopiesNotExecutes.
  • Token leakage: ClassifyProviderError catches *anthropic.Error via errors.As and extracts only the message field, never the raw JSON body. Raw errors appear only at log.Debug level. Tool handler errors go through classifyToolError which returns fixed class labels. The watcher buffer and approvals strip never contain raw API responses.
  • MaxIterations safety: Guarded at two layers — resolveInvestigationConfig only overrides if v > 0, and watcherInvestigateCmd clamps <= 0 to 6. SDK treats MaxIterations: 0 as unlimited, but this code never passes 0.
  • Investigation timeout: context.WithTimeout(context.Background(), cfg.timeout) at investigation.go:123. Default 90s, configurable, guarded against non-positive.
  • Race conditions: go test -race passes on all PR packages.
  • No new deadcode beyond the ~32 pre-existing entries in main.

Bottom Line

The seven fixes from the review rounds all hold. The feature is architecturally sound: policy enforcement is correct, the write path requires explicit user Accept, no exec path exists for suggested commands, and token leakage is properly guarded.

Two must-fix items remain — both are the "unwired code" test gap pattern this project keeps hitting. The production call sites that bridge buildAskFromVerdict → strip → Accept → PD write are untested at the integration level. The individual unit tests pass regardless of whether these call sites exist. These should be addressed before merge to prevent silent regressions.

@clcollins clcollins left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #418 Final Verification Pass — Report

Summary

All seven verification categories are clean. No must-fix issues found. The five fix rounds held, and no new gaps were discovered.


1. Mutation Testing (Verified by Running)

All three known mutations were re-executed on a clean tree and confirmed killed:

ID Site Mutation Killing Test Result
M1 pkg/tui/tui.go:731 m.approvals.Add(ask)_ = ask TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk KILLED
M2 pkg/tui/msgHandlers.go:1126 return m, cmdreturn m, nil TestUpdate_ApprovalsEnter_ReturnsCmdThatPostsNote KILLED
M3 pkg/tui/tui.go:734-739 m.approvals.Add(Ask{...})_ = ta TestUpdate_InvestigationMsg_ToolAsksAddToolPermissionAsks KILLED

Each mutation compiled, was tested, the named test failed, and the mutation was reverted.

2. M4 Hunting — No Fourth Mutation Survived

Grepped all approvals.Add, .Action, Accept, Dismiss call sites and stubbed each in turn:

  • AskSuggestedCommand Action → nil: killed by TestBuildAskFromVerdict_SuggestedCommand_CopiesNotExecutes
  • Dismiss → no-op: killed by TestUpdate_ApprovalsDismiss_DoesNotReturnActionCmd
  • postAINoteCmd → nil result: killed by TestBuildAskFromVerdict_DraftNote_ActionCallsPDAddNote
  • gatedTool.Execute bypass policy: killed by TestHeadline_DenyEverything_ZeroHandlerInvocations + TestGatedBetaTools_AskCallsCallback
  • Action != "" guard inverted: killed by TestUpdate_InvestigationMsg_ActionableVerdictAddsAsk

No surviving mutation found. The approvals chain is well-covered.

3. Verdict → PagerDuty Write Chain (Verified by Reading)

CLEAN. Traced buildAskFromVerdictapprovals.Add → user A keypress → switchApprovalsFocusMode → user Enterapprovals.Acceptask.Action()postAINoteCmd.

  • Action closures are never invoked on Add, Render, RenderExpanded, or Dismiss
  • Accept is only called from switchApprovalsFocusMode on explicit Enter keypress
  • No auto-fire, no fire-on-render, no fire-from-message-handler paths

4. Policy Engine Bypass Resistance (Verified by Reading + Running)

CLEAN.

  • gatedTool.Execute checks decide() before every handler invocation
  • Empty tool name → Deny (policy.go:44)
  • Unknown mode → Deny (policy.go:82 default case)
  • GatedBetaTools takes a read-locked snapshot — post-snapshot registrations invisible
  • decide is always a non-nil closure (investigation.go:79-81)
  • isInList uses exact string match — no case-variant bypass
  • Test coverage: TestDecide_Exhaustive covers all mode×class combinations + edge cases

5. SuggestedCommand — No Exec Path (Verified by Reading)

CLEAN. copyToClipboardCmd (model.go:1011-1014) returns setStatusMsg — displays text in the status bar with "copy to terminal" instruction. No exec.Command, no os/exec import in pkg/ai/. All registered tool handlers are ClassRead. Verified by TestBuildAskFromVerdict_SuggestedCommand_CopiesNotExecutes.

6. Token Leakage (Verified by Reading)

CLEAN. All AI error paths use ClassifyProviderError. Tool handlers use formatError which:

  • Logs raw errors at Debug level only (log.Debug)
  • Returns classified categories to tool results: "timeout", "auth error", "not found", "network error", "request failed"
  • Tested by TestHandler_ErrorDoesNotLeakInternals and TestHandler_FormatErrorIncludesClass

Investigation error path (tui.go:711-712): raw error at Debug, classified at Warn, watcher buffer gets observation text only.

7. Doc and Test Accuracy (Verified by Running)

  • All test names in PR body exist: checked every Test\w+ token against grep -rn 'func Test' — zero mismatches
  • Plan doc: AsBetaTools references correctly describe it as removed (D1 fix holds); no stale symbol references in code (grep -rn 'AsBetaTools' pkg/ returns nothing)
  • Deadcode: ~32 entries, all pre-existing mocks/helpers/unused config functions; no new entries from PR files
  • Race tests: go test -race ./pkg/... — all 15 packages pass, zero data races

8. Test Suite (Verified by Running)

  • go test ./pkg/... -count=1: all pass (15/15 packages)
  • go test -race ./pkg/... -count=1: all pass, no data races
  • cmd/ has one pre-existing env failure (TestConfigureLogging_SetsLogWriter writes to /var/log/srepd.log — read-only filesystem in this env). Not related to PR #418.

Nice-to-Have

None identified. The implementation is clean and well-tested.

Verdict

Ship it. All five fix rounds hold. No new issues. The tool registry, policy engine, and approvals chain are correctly wired and comprehensively tested against mutation.

@clcollins
clcollins force-pushed the srepd/ai-p3-tools-policy branch from 73d2f4c to 1d78873 Compare August 5, 2026 00:10
@clcollins
clcollins merged commit bccb0fb into main Aug 5, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant