Skip to content

Commit 95f5fa4

Browse files
Dumbrisclaude
andauthored
feat(mcp): agent-discoverable disabled tools (spec 049) (#476)
* docs(spec): agent-discoverable disabled tools design Follow-up to PR #468. Opt-in include_disabled on retrieve_tools + conditional counts on upstream_servers, 5-state classifier, reactive discovery nudges. Token-cost-zero until exercised. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(049): speckit spec for agent-discoverable disabled tools Converts the brainstormed design (docs/superpowers/specs/2026-05-18-...) into canonical speckit format. Opt-in include_disabled discovery, 5-state status + remediation map, conditional per-server counts, reactive nudges. No enforcement/storage change. Related #468 ## Changes - specs/049-agent-discoverable-disabled-tools/spec.md - specs/049-agent-discoverable-disabled-tools/checklists/requirements.md ## Testing - N/A (spec only); requirements checklist all items pass * docs(049): speckit plan + phase-0/1 artifacts Related #468 ## Changes - plan.md (constitution check PASS, no violations) - research.md (decisions + codebase facts; no open unknowns) - data-model.md (5-state status enum, additive response shapes) - contracts/mcp-deltas.md (retrieve_tools + upstream_servers deltas) - quickstart.md (curl + live-MCP verification recipe) - CLAUDE.md active-technologies updated by speckit agent-context ## Testing - N/A (planning docs only) * docs(049): speckit tasks (18, TDD, by user story) Related #468 ## Changes - tasks.md: Setup, Foundational (classifier+types), US1 MVP, US2, US3, Polish ## Testing - N/A (task plan only) * feat(049): foundational classifier + additive response types Related #468 ## Changes - contracts: DisabledToolStatus consts, LockedToolEntry, ServerToolCounts - runtime: ClassifyDisabledTool (pure, 5-state precedence, read-only) - tests: precedence/unknown/pending table + 1k benchmark (6.6 ns/op) ## Testing - go test ./internal/runtime -run TestClassifyDisabledTool: PASS - BenchmarkClassifyDisabledTool: 6.6 ns/op (<<100ms budget) * feat(049): US1 — opt-in include_disabled tool discovery Related #468 ## Changes - retrieve_tools: include_disabled param + one-line schema hint - handler: split callable/disabled, agent-scope before classify, cap min(limit,10), once-per-response remediation map - p.classifyDisabledTool (storage-sourced, mirrors runtime classifier) - in-memory include_disabled usage counter (no persistence) ## Testing - TestDisabledDiscovery_{DefaultPathUnchanged,OptIn,CapAtTen,UsageCounter}: PASS - regressions (ExcludesDisabled, CallBlockedTool, BlockedToolMessageFor): PASS * feat(049): US2 — discovery pointer + zero-result nudge Related #468 ## Changes - blockedToolMessageFor: append include_disabled:true pointer (both branches) - retrieve_tools: when 0 callable + droppedCount>0 + flag off, emit a one-line 'notice' count nudge (no inline entries) ## Testing - TestBlockedToolMessage_DiscoveryPointer, TestDisabledDiscovery_ZeroResultNudge: PASS * feat(049): US3 — conditional per-server tool counts Related #468 ## Changes - upstream_servers list/get: emit ServerToolCounts only when a non-callable count > 0 (all-callable servers gain 0 bytes) - classifyServerToolStatus: single storage-sourced truth (runtime- independent); classifyDisabledTool now delegates to it (no drift) ## Testing - TestServerToolCounts_Conditional + full server/runtime suites: PASS * docs(049): built-in tools note for include_disabled + server counts Related #468 * test(049): live curl+MCP verification results + task closeout Related #468 ## Changes - quickstart.md: verification-results table (§1-§5 PASS), documents the pre-existing #468 config->runtime disabled_tools gap (out of 049 scope) - tasks.md: all phases checked off ## Testing - runtime/server/contracts unit suites: PASS; verify-oas: PASS - live MCP+curl: default unchanged, include_disabled, counts, blocked msg * docs(049): trim CLAUDE.md additions (speckit auto-append cruft + terser notes) Related #468 check-size is pre-existing/advisory (CLAUDE.md was 39347 chars at base, already >25k; #468 merged through the same failing check). This commit minimizes 049's footprint regardless. --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 3ffa41e commit 95f5fa4

15 files changed

Lines changed: 1645 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,12 +361,12 @@ See [docs/configuration.md](docs/configuration.md) for complete reference.
361361
## MCP Protocol
362362
363363
### Built-in Tools
364-
- **`retrieve_tools`** - BM25 keyword search across all upstream tools, returns annotations and recommended tool variant
364+
- **`retrieve_tools`** - BM25 keyword search across all upstream tools, returns annotations and recommended tool variant. Spec 049: opt-in `include_disabled:true` adds a `disabled[]`+`remediation` view of locked tools (5-state `status`); default output unchanged.
365365
- **`call_tool_read`** - Proxy read-only tool calls to upstream servers (Spec 018)
366366
- **`call_tool_write`** - Proxy write tool calls to upstream servers (Spec 018)
367367
- **`call_tool_destructive`** - Proxy destructive tool calls to upstream servers (Spec 018)
368368
- **`code_execution`** - Execute JavaScript to orchestrate multiple tools (disabled by default)
369-
- **`upstream_servers`** - CRUD operations for server management
369+
- **`upstream_servers`** - CRUD operations for server management. Spec 049: list/get entries carry a conditional `tools` count block only when a server has ≥1 non-callable tool.
370370
- **`quarantine_security`** - Security quarantine management: list/inspect quarantined servers, inspect/approve/approve-all tools (Spec 032)
371371
372372
**Tool Format**: `<serverName>:<toolName>` (e.g., `github:create_issue`)
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Agent-Discoverable Disabled Tools — Design
2+
3+
- **Date:** 2026-05-18
4+
- **Status:** Approved (brainstorming), pending implementation plan
5+
- **Author:** Claude (brainstormed with Algis)
6+
- **Builds on:** PR #468 (`feat/config-tool-allowlist` — layered config tool filter, introduces `config_denied` and `Runtime.IsToolConfigDenied`)
7+
- **Delivery:** Standalone follow-up PR. The four UX fixes (#1#4) from the #468 review land in #468 itself; this design is everything else.
8+
9+
## 1. Problem
10+
11+
`mcpproxy` exposes a curated, filtered tool surface to agents. Today `retrieve_tools`
12+
runs `isToolCallable()` as a hard post-search filter: any disabled or config-denied
13+
tool is dropped from results and treated as non-existent. `upstream_servers` only
14+
counts callable tools.
15+
16+
Consequence: when a capability an agent needs to complete a task exists on an
17+
upstream but is locked (by operator config, by the user, by quarantine, or because
18+
the server is off), the agent gets **zero signal**. It cannot tell the user
19+
"the `delete_repo` tool exists but is disabled — enable it to proceed", and it
20+
cannot distinguish a lock the user can lift (UI toggle) from operator policy the
21+
user *cannot* lift (mcp_config.json), so any suggestion it makes is a guess.
22+
23+
## 2. Goal & non-goals
24+
25+
**Goal:** Let an agent, on demand, discover that a relevant capability exists but
26+
is locked, learn *why* in a machine-branchable way, and relay the *correct*
27+
remediation to the user/operator — at near-zero token cost when the feature is
28+
not exercised.
29+
30+
**Non-goals:**
31+
32+
- No change to enforcement. A discovered locked tool remains non-callable.
33+
`isToolCallable()` is untouched; this is a discovery/observability layer only.
34+
- No change to default `retrieve_tools` / `upstream_servers` output when the
35+
feature is not triggered (byte-for-byte backward compatible).
36+
- No new persistent storage. Classification is computed at query time from data
37+
already loaded (config + approval records + StateView snapshot).
38+
39+
## 3. Key facts established during brainstorming
40+
41+
- **Disabled tools are already in the Bleve index.** `DiscoverAndIndexToolsForServer`
42+
indexes everything `client.ListTools()` returns with no disabled/config filter;
43+
filtering is purely query-time in the `callableResults` loop of
44+
`handleRetrieveToolsWithMode` (`internal/server/mcp.go`). Surfacing locked tools
45+
is therefore a change to *that loop*, not a new data path.
46+
- **`isToolCallable` collapses several distinct reasons** (server off, config-denied,
47+
user-disabled, quarantine pending/changed, storage error). The discovery layer
48+
re-derives a precise reason; enforcement stays collapsed.
49+
- **`Runtime.IsToolConfigDenied(server, tool)`** (added in #468) is the
50+
config-vs-user discriminator and is reused as-is.
51+
52+
## 4. Design
53+
54+
### 4.1 `retrieve_tools` — opt-in `include_disabled`
55+
56+
- New optional boolean parameter `include_disabled` (default `false`). Tool schema
57+
description gains exactly one sentence (the "static hint", §4.4).
58+
- In `handleRetrieveToolsWithMode`, the existing loop that builds `callableResults`
59+
changes: when a result is dropped by `isToolCallable`, **count it always**; and
60+
**if `include_disabled` is true**, classify it (§4.3) and append to a separate
61+
`disabledResults` slice.
62+
- Agent-scope filtering (`authCtx.CanAccessServer`) is applied **before**
63+
classification — an agent never sees locked tools on servers it cannot access.
64+
- Response ordering: callable results first with their **existing ranking
65+
unchanged**, then `disabledResults`, capped at `min(limit, 10)` entries to bound
66+
tokens against a pathologically restrictive config.
67+
- Per disabled entry (lean shape):
68+
- `name`
69+
- `server`
70+
- `description` (the existing one-line description; already short — not truncated)
71+
- `status` (enum, §4.3)
72+
- A single `remediation` map is emitted **once** at the top level of the response,
73+
containing only the keys for statuses actually present in `disabledResults`
74+
(§4.3). Per-tool remediation prose is **not** emitted.
75+
- Telemetry: increment an in-memory `include_disabled` usage counter (consistent
76+
with spec 042 — in-memory only, never persisted) so adoption is observable.
77+
78+
### 4.2 `upstream_servers` — conditional counts
79+
80+
- `operation="list"` and `operation="get"`: each server entry gains a `tools`
81+
block **only when at least one non-callable count is > 0**:
82+
83+
```json
84+
"tools": { "callable": N, "disabled_by_config": N, "disabled_by_user": N, "pending_approval": N }
85+
```
86+
87+
Zero-valued sub-keys are omitted; the whole `tools` block is omitted when every
88+
tool is callable. Computed from the StateView snapshot the existing
89+
`getVisibleToolCount` path already walks — one extra classification pass, no new
90+
storage reads.
91+
92+
### 4.3 Status taxonomy & classification
93+
94+
`status` is one of five values. Classification order is **first match wins**:
95+
96+
| Order | Condition | `status` | `remediation` text (emitted once if present) |
97+
|------|-----------|----------|----------------------------------------------|
98+
| 1 | Server not enabled | `server_disabled` | "Its server is disabled. Ask the user to enable the server first." |
99+
| 2 | `IsToolConfigDenied` true | `disabled_by_config` | "Locked by operator policy in mcp_config.json (enabled_tools/disabled_tools). The user cannot enable this from the UI; ask the operator to change the server config." |
100+
| 3 | Approval record `Disabled` | `disabled_by_user` | "Disabled by the user. Ask the user to re-enable it in the mcpproxy UI (Server detail → Tools) or via the API." |
101+
| 4 | Approval status pending/changed | `pending_approval` | "Awaiting security approval. Ask the user to review and approve it in the mcpproxy UI." |
102+
| 5 | Storage error / indeterminate | `disabled_unknown` | "Reason undetermined; check server logs." |
103+
104+
`disabled_unknown` (5th bucket) exists so a transient storage error never causes a
105+
*wrong* remediation (e.g. telling the user to toggle a UI switch for a
106+
config-locked tool). The four happy-path states stay clean.
107+
108+
### 4.4 Reactive triggers (discoverability)
109+
110+
Option C's only weakness is the agent not knowing the flag exists. Closed two ways:
111+
112+
1. **Static hint** (one-time, cheap): one sentence appended to the `retrieve_tools`
113+
parameter/tool description — *"Set `include_disabled:true` to also surface
114+
tools that exist but are currently locked by config, user, or quarantine,
115+
with remediation guidance."*
116+
2. **Reactive nudges:**
117+
- The status-aware `TOOL_BLOCKED` message (the #1 fix shipping in #468) gains,
118+
for the config/user/pending cases: *"Run retrieve_tools with
119+
include_disabled:true to see locked capabilities and remediation."*
120+
- When `retrieve_tools` returns **0 callable results** but the always-on
121+
drop-counter (§4.1) is > 0, append a one-line note to the result:
122+
*"N relevant tools exist but are locked; retry with include_disabled:true
123+
for details."* — count only, never the entries, so the nudge is a few
124+
tokens regardless of how many are locked.
125+
126+
### 4.5 Error handling & edges
127+
128+
- Classification storage error → `disabled_unknown` (never a misleading
129+
remediation).
130+
- `server_disabled` reliability caveat: a fully-disabled server does not re-list
131+
its tools, so its entries surface in `retrieve_tools` only if stale in the
132+
index. The authoritative signal for a fully-off server is the
133+
`upstream_servers` server `state`, not search. Documented as a known limitation;
134+
no code attempts to "freshen" a disabled server's tools.
135+
- Backward compatibility: default path (`include_disabled` absent/false, all
136+
tools callable) is byte-for-byte unchanged. New behavior is additive behind the
137+
flag and the conditional `tools` block.
138+
139+
## 5. Components & boundaries
140+
141+
| Unit | Responsibility | Depends on |
142+
|------|----------------|------------|
143+
| `classifyDisabledTool(server, tool) -> status` | Pure mapping from (config, approval record, server-enabled state) to one of 5 statuses. No I/O beyond reads already done. | `Runtime.IsToolConfigDenied`, storage approval lookup, server-enabled check |
144+
| `retrieve_tools` handler change | Split dropped results into `disabledResults`, cap, attach `remediation` map, emit 0-result nudge | `classifyDisabledTool` |
145+
| `upstream_servers` list/get change | Per-server count rollup, conditional emit | `classifyDisabledTool`, StateView snapshot |
146+
| `TOOL_BLOCKED` message (#468) | Status-aware text + flag pointer | `IsToolConfigDenied` (already in #468) |
147+
148+
`classifyDisabledTool` is the single source of truth for "why is this tool not
149+
callable" used by both surfaces — it can be unit-tested in isolation against the
150+
five branches without standing up an MCP server.
151+
152+
## 6. Testing
153+
154+
- **Classifier (`internal/server` or `internal/runtime`):** table test, one case
155+
per status including first-match-wins ordering (e.g. a tool that is both
156+
config-denied *and* user-disabled resolves to `disabled_by_config`) and the
157+
`disabled_unknown` fallback on injected storage error.
158+
- **`retrieve_tools`:**
159+
- `include_disabled` absent/false → result identical to today (regression guard,
160+
reuse existing fixtures).
161+
- `include_disabled` true → callable-first ordering preserved; cap of
162+
`min(limit,10)` enforced; `remediation` map keyed only by present statuses;
163+
agent-scope filter still applied before classification.
164+
- 0 callable + locked matches → nudge note present with correct count;
165+
0 callable + no locked matches → no nudge.
166+
- **`upstream_servers`:** `tools` block omitted when all callable; present and
167+
correct (zero sub-keys omitted) when mixed.
168+
- **OAS:** regenerate `oas/swagger.yaml` + `oas/docs.go` for the new
169+
`include_disabled` param and disabled-entry/`tools` shapes; run
170+
`./scripts/verify-oas-coverage.sh`.
171+
- Full suite: `go test ./internal/... -race`, `./scripts/test-api-e2e.sh`.
172+
173+
## 7. Out of scope / future
174+
175+
- Surfacing locked tools in the Web UI search (the UI already shows per-tool lock
176+
badges on the server detail page from #468).
177+
- A "request enable" workflow where the agent programmatically asks the user to
178+
approve — this design only *informs*; acting on it is the agent's/user's call.
179+
- Freshening a disabled server's tool list for more reliable `server_disabled`
180+
discovery.

internal/contracts/types.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,41 @@ type Tool struct {
200200
ConfigDenied bool `json:"config_denied,omitempty"`
201201
}
202202

203+
// DisabledToolStatus is the single machine-branchable reason a tool exists but
204+
// is not callable (Spec 049). Exactly one value per locked tool, assigned by
205+
// fixed first-match precedence (server-off → config → user → pending → unknown).
206+
type DisabledToolStatus = string
207+
208+
const (
209+
DisabledStatusServerDisabled DisabledToolStatus = "server_disabled"
210+
DisabledStatusByConfig DisabledToolStatus = "disabled_by_config"
211+
DisabledStatusByUser DisabledToolStatus = "disabled_by_user"
212+
DisabledStatusPendingApproval DisabledToolStatus = "pending_approval"
213+
DisabledStatusUnknown DisabledToolStatus = "disabled_unknown"
214+
)
215+
216+
// LockedToolEntry is the lean discovery shape for a non-callable tool returned
217+
// by retrieve_tools when include_disabled=true (Spec 049). No input schema —
218+
// the agent only needs enough to tell the user the capability exists and why.
219+
type LockedToolEntry struct {
220+
Name string `json:"name"`
221+
Server string `json:"server"`
222+
Description string `json:"description"`
223+
Status DisabledToolStatus `json:"status"`
224+
}
225+
226+
// ServerToolCounts is a compact per-server rollup of tool callability,
227+
// attached to an upstream_servers entry only when a non-callable count > 0
228+
// (Spec 049). Zero-valued reasons are omitted to keep the payload minimal.
229+
type ServerToolCounts struct {
230+
Callable int `json:"callable"`
231+
DisabledByConfig int `json:"disabled_by_config,omitempty"`
232+
DisabledByUser int `json:"disabled_by_user,omitempty"`
233+
PendingApproval int `json:"pending_approval,omitempty"`
234+
ServerDisabled int `json:"server_disabled,omitempty"`
235+
DisabledUnknown int `json:"disabled_unknown,omitempty"`
236+
}
237+
203238
// SearchResult represents a search result for tools
204239
type SearchResult struct {
205240
Tool Tool `json:"tool"`
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package runtime
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
10+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
11+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
12+
)
13+
14+
// ClassifyDisabledTool must map (server, tool) to exactly one status by fixed
15+
// first-match precedence: server-off → config → user → pending → unknown.
16+
func TestClassifyDisabledTool_Precedence(t *testing.T) {
17+
rt := setupConfigFilterRuntime(t, []*config.ServerConfig{
18+
{Name: "off", Enabled: false},
19+
{Name: "github", Enabled: true, EnabledTools: []string{"list_issues"}},
20+
{Name: "plain", Enabled: true},
21+
})
22+
23+
// server_disabled wins even over a config that would also deny.
24+
assert.Equal(t, contracts.DisabledStatusServerDisabled,
25+
rt.ClassifyDisabledTool("off", "anything"))
26+
27+
// config-denied (not in allowlist) outranks any user/pending state.
28+
assert.Equal(t, contracts.DisabledStatusByConfig,
29+
rt.ClassifyDisabledTool("github", "create_issue"))
30+
31+
// config + user-disabled on the same tool → config wins (precedence).
32+
require.NoError(t, rt.SetToolEnabled("github", "create_issue", false, "user"))
33+
assert.Equal(t, contracts.DisabledStatusByConfig,
34+
rt.ClassifyDisabledTool("github", "create_issue"))
35+
36+
// user-disabled, no config filter on this server.
37+
require.NoError(t, rt.SetToolEnabled("plain", "do_thing", false, "user"))
38+
assert.Equal(t, contracts.DisabledStatusByUser,
39+
rt.ClassifyDisabledTool("plain", "do_thing"))
40+
}
41+
42+
func TestClassifyDisabledTool_Unknown(t *testing.T) {
43+
rt := setupConfigFilterRuntime(t, []*config.ServerConfig{
44+
{Name: "plain", Enabled: true},
45+
})
46+
47+
// Server not in config at all → unknown (never a misleading remediation).
48+
assert.Equal(t, contracts.DisabledStatusUnknown,
49+
rt.ClassifyDisabledTool("nonexistent", "x"))
50+
51+
// Enabled server, no config filter, no approval record, tool not otherwise
52+
// blocked → indeterminate → unknown (classifier is only called for
53+
// non-callable tools; absent a concrete reason it must not lie).
54+
assert.Equal(t, contracts.DisabledStatusUnknown,
55+
rt.ClassifyDisabledTool("plain", "no_record_tool"))
56+
}
57+
58+
func TestClassifyDisabledTool_PendingApproval(t *testing.T) {
59+
rt := setupConfigFilterRuntime(t, []*config.ServerConfig{
60+
{Name: "plain", Enabled: true},
61+
})
62+
// A pending approval record (not user-disabled) → pending_approval.
63+
require.NoError(t, rt.storageManager.SaveToolApproval(&storage.ToolApprovalRecord{
64+
ServerName: "plain", ToolName: "pending_tool",
65+
Status: storage.ToolApprovalStatusPending,
66+
}))
67+
assert.Equal(t, contracts.DisabledStatusPendingApproval,
68+
rt.ClassifyDisabledTool("plain", "pending_tool"))
69+
}
70+
71+
// BenchmarkClassifyDisabledTool guards Constitution I: the classify path must
72+
// stay far under the 100ms/1k-tools discovery budget.
73+
func BenchmarkClassifyDisabledTool(b *testing.B) {
74+
rt := setupConfigFilterRuntime(&testing.T{}, []*config.ServerConfig{
75+
{Name: "github", Enabled: true, DisabledTools: []string{"delete_repo"}},
76+
})
77+
b.ResetTimer()
78+
for i := 0; i < b.N; i++ {
79+
_ = rt.ClassifyDisabledTool("github", "delete_repo")
80+
}
81+
}

internal/runtime/tool_quarantine.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"go.uber.org/zap"
1313

1414
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
15+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
1516
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
1617
)
1718

@@ -940,3 +941,52 @@ func (r *Runtime) IsToolConfigDenied(serverName, toolName string) bool {
940941
}
941942
return false
942943
}
944+
945+
// ClassifyDisabledTool returns the single machine-branchable reason a tool is
946+
// not callable, by fixed first-match precedence (Spec 049). Pure, request-time,
947+
// read-only — nothing is written to BBolt. Only meaningful for tools that are
948+
// already known non-callable; it never lies (indeterminate → unknown).
949+
func (r *Runtime) ClassifyDisabledTool(serverName, toolName string) contracts.DisabledToolStatus {
950+
// Resolve the server config. Unknown server → unknown (never a misleading
951+
// remediation for a server we cannot reason about).
952+
var sc *config.ServerConfig
953+
for _, candidate := range r.Config().Servers {
954+
if candidate.Name == serverName {
955+
sc = candidate
956+
break
957+
}
958+
}
959+
if sc == nil {
960+
return contracts.DisabledStatusUnknown
961+
}
962+
963+
// 1. Whole server off.
964+
if !sc.Enabled {
965+
return contracts.DisabledStatusServerDisabled
966+
}
967+
968+
// 2. Operator config policy — outranks user/pending; the user cannot lift
969+
// this from the UI.
970+
if !sc.IsToolAllowedByConfig(toolName) {
971+
return contracts.DisabledStatusByConfig
972+
}
973+
974+
// 3/4. User-disabled vs pending security approval, from the approval record.
975+
record, err := r.GetToolApproval(serverName, toolName)
976+
switch {
977+
case err == nil && record != nil:
978+
if record.Disabled {
979+
return contracts.DisabledStatusByUser
980+
}
981+
if record.Status == storage.ToolApprovalStatusPending ||
982+
record.Status == storage.ToolApprovalStatusChanged {
983+
return contracts.DisabledStatusPendingApproval
984+
}
985+
case errors.Is(err, storage.ErrToolApprovalNotFound):
986+
// No record — fall through to unknown below.
987+
}
988+
989+
// 5. Indeterminate (storage error, or no concrete reason found) — never
990+
// emit a wrong remediation.
991+
return contracts.DisabledStatusUnknown
992+
}

0 commit comments

Comments
 (0)