Skip to content

Commit c3e99ac

Browse files
electrolobzikclaude
andcommitted
fix(security): isToolCallable fail-closed on non-not-found storage errors
The new isToolCallable check returned true (callable) for any non-nil GetToolApproval error other than the new ErrToolApprovalNotFound sentinel. A transient BBolt mmap-remap during compaction, a decode failure, or any other read error would silently re-enable a tool the operator had previously persisted as Disabled=true — the inverse of the Spec 032 rug-pull bypass that setToolEnabledNoEmit already guards against on the write side. Mirror that pattern on the read side: switch { case err == nil: if approval != nil && approval.Disabled { return false } case errors.Is(err, storage.ErrToolApprovalNotFound): // no record → default callable (matches prior fallthrough) default: // real storage error → fail closed and log return false } The "no record → callable" branch is preserved so the synthesis path for never-toggled tools still works (writes happen lazily on first toggle; reads before any toggle should still expose the tool by default). A WARN log fires on the fail-closed path so operators can correlate storage health with sudden tool unavailability. Regression test: TestIsToolCallable_FailsClosedOnStorageError writes corrupt non-JSON bytes directly into the tool_approvals bucket via the storage manager's GetDB hook, then asserts isToolCallable returns false. Exercises the real json.Unmarshal error path, not a mocked storage layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f548de1 commit c3e99ac

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

internal/server/mcp.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4575,7 +4575,21 @@ func (p *MCPProxyServer) isToolCallable(serverName, toolName string) bool {
45754575
}
45764576

45774577
approval, err := p.storage.GetToolApproval(serverName, toolName)
4578-
if err == nil && approval != nil && approval.Disabled {
4578+
switch {
4579+
case err == nil:
4580+
if approval != nil && approval.Disabled {
4581+
return false
4582+
}
4583+
case errors.Is(err, storage.ErrToolApprovalNotFound):
4584+
// no record → use the implicit default (enabled / callable)
4585+
default:
4586+
// real storage error — fail closed to avoid silently re-enabling a
4587+
// tool the user disabled. A transient BBolt mmap-remap during compaction
4588+
// should not cause a Disabled=true record to be ignored.
4589+
p.logger.Warn("isToolCallable: storage error treated as not-callable",
4590+
zap.String("server", serverName),
4591+
zap.String("tool", toolName),
4592+
zap.Error(err))
45794593
return false
45804594
}
45814595

internal/server/mcp_blocked_tools_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/mark3labs/mcp-go/mcp"
99
"github.com/stretchr/testify/assert"
1010
"github.com/stretchr/testify/require"
11+
"go.etcd.io/bbolt"
1112

1213
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
1314
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
@@ -148,3 +149,42 @@ func TestReenableTool_VisibleAndCallable(t *testing.T) {
148149

149150
assert.True(t, proxy.isToolCallable("context7", "resolve-library-id"))
150151
}
152+
153+
// TestIsToolCallable_FailsClosedOnStorageError verifies that isToolCallable is
154+
// fail-closed: when GetToolApproval returns a real storage error (not
155+
// ErrToolApprovalNotFound), the function must return false (not callable) so
156+
// that a transient I/O error cannot silently re-enable a tool the user disabled.
157+
//
158+
// We trigger a real storage error by writing corrupt (non-JSON) bytes directly
159+
// into the tool_approvals BBolt bucket for the target key. When GetToolApproval
160+
// reads those bytes and json.Unmarshal fails, it returns a decode error that is
161+
// NOT wrapped in ErrToolApprovalNotFound — exactly the "real error" case the
162+
// fix must handle.
163+
func TestIsToolCallable_FailsClosedOnStorageError(t *testing.T) {
164+
proxy := createTestMCPProxyServer(t)
165+
166+
// Register an enabled server so the early-exit check passes.
167+
require.NoError(t, proxy.storage.SaveUpstreamServer(&config.ServerConfig{
168+
Name: "badstore",
169+
Enabled: true,
170+
}))
171+
172+
// Write corrupt (non-JSON) bytes directly into the tool_approvals bucket for
173+
// the target key. This bypasses the normal SaveToolApproval path and causes
174+
// GetToolApproval to return a json.UnmarshalError — a real error that is NOT
175+
// ErrToolApprovalNotFound.
176+
corruptKey := storage.ToolApprovalKey("badstore", "fragile-tool")
177+
err := proxy.storage.GetDB().Update(func(tx *bbolt.Tx) error {
178+
b := tx.Bucket([]byte(storage.ToolApprovalBucket))
179+
if b == nil {
180+
t.Fatal("tool_approvals bucket not found")
181+
}
182+
return b.Put([]byte(corruptKey), []byte("NOT VALID JSON {{{"))
183+
})
184+
require.NoError(t, err, "writing corrupt bytes into BBolt must succeed")
185+
186+
// isToolCallable must return false (fail-closed) rather than true (fail-open)
187+
// when it encounters a real storage error reading the approval record.
188+
assert.False(t, proxy.isToolCallable("badstore", "fragile-tool"),
189+
"isToolCallable must be fail-closed on storage decode errors")
190+
}

0 commit comments

Comments
 (0)