Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Expand Down Expand Up @@ -368,12 +368,12 @@
- **`call_tool_write`** - Proxy write tool calls to upstream servers (Spec 018)
- **`call_tool_destructive`** - Proxy destructive tool calls to upstream servers (Spec 018)
- **`code_execution`** - Execute JavaScript to orchestrate multiple tools (disabled by default)
- **`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.
- **`quarantine_security`** - Security quarantine management: list/inspect quarantined servers, inspect/approve/approve-all tools (Spec 032)
- **`upstream_servers`** - CRUD for server management (Spec 049).
- **`quarantine_security`** - Security quarantine: list/inspect quarantined servers, inspect/approve/approve-all tools, and block/block-all (atomic approve+disable) (Spec 032).

**Tool Format**: `<serverName>:<toolName>` (e.g., `github:create_issue`)

**Intent Declaration (Spec 018)**: Tool variants enable granular IDE permission control. The `operation_type` is automatically inferred from the tool variant (`call_tool_read` → "read", etc.). Optional `intent` fields for audit:
**Intent Declaration (Spec 018)**: Tool variants enable granular IDE permission control; `operation_type` is inferred from the variant (`call_tool_read` → "read"). Optional `intent` fields for audit:
```json
{
"intent": {
Expand Down Expand Up @@ -406,6 +406,7 @@
| `DELETE /api/v1/tokens/{name}` | Revoke agent token |
| `POST /api/v1/tokens/{name}/regenerate` | Regenerate agent token secret |
| `POST /api/v1/servers/{id}/tools/approve` | Approve pending/changed tools (Spec 032) |
| `POST /api/v1/servers/{id}/tools/block` | Block (approve+disable) tools (Spec 032) |
| `GET /api/v1/servers/{id}/tools/{tool}/diff` | View tool description/schema changes (Spec 032) |
| `GET /api/v1/servers/{id}/tools/export` | Export tool approval records (Spec 032) |
| `POST /api/v1/feedback` | Submit feedback/bug report (proxied to GitHub Issues) |
Expand Down
36 changes: 36 additions & 0 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,42 @@ Or approve all pending/changed tools:
}
```

#### POST /api/v1/servers/{name}/tools/block

Atomically **block** tools = approve **and** disable them in a single server-side
operation. Use this to acknowledge a pending/changed tool (clearing its
quarantine flag) while keeping it hidden from MCP clients. The approve and
disable land in one write per tool, so a tool is never left in the
approved+enabled state.

**Request Body:**
```json
{
"tools": ["create_issue", "delete_repo"]
}
```

Or block all pending/changed tools:
```json
{
"block_all": true
}
```

**Response:**
```json
{
"success": true,
"data": {
"blocked": 2,
"tools": ["create_issue", "delete_repo"],
"message": "Blocked 2 tools for server github-server"
}
}
```

Returns `400` if neither `tools` nor `block_all` is provided.

#### GET /api/v1/servers/{name}/tools/{tool}/diff

Get the description/schema diff for a changed tool. The response exposes every
Expand Down
17 changes: 17 additions & 0 deletions docs/features/security-quarantine.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,23 @@ See [Tool Quarantine](./tool-quarantine.md) for complete documentation on:
- Configuration: `quarantine_enabled` and `skip_quarantine`
- REST API endpoints for tool approval management

### Block (approve + disable)

When reviewing a pending or changed tool you may want to **acknowledge it but
keep it hidden** from MCP clients — for example, dismissing a noisy "changed"
flag for a tool you never intend to use. The **block** operation does this
atomically: it approves the tool (clearing the quarantine flag) **and** disables
it in a single, all-or-nothing server-side write, so a tool is never left in the
approved+enabled state.

- **REST**: `POST /api/v1/servers/{id}/tools/block` with `{"tools":[...]}` or
`{"block_all": true}`.
- **MCP**: `quarantine_security` operations `block_tool` (with `name` +
`tool_name`) and `block_all_tools` (with `name`).

A blocked tool can be re-exposed later with the normal enable operation
(`POST /api/v1/servers/{id}/tools/{tool}/enabled` with `{"enabled": true}`).

## Disabling Quarantine

**Not recommended**, but you can opt out of quarantine globally by setting a
Expand Down
4 changes: 4 additions & 0 deletions internal/httpapi/contracts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,10 @@ func (m *MockServerController) ListToolApprovals(_ string) ([]*storage.ToolAppro
}
func (m *MockServerController) ApproveTools(_ string, _ []string, _ string) error { return nil }
func (m *MockServerController) ApproveAllTools(_ string, _ string) (int, error) { return 0, nil }
func (m *MockServerController) BlockTools(_ string, _ []string, _ string) (int, error) {
return 0, nil
}
func (m *MockServerController) BlockAllTools(_ string, _ string) (int, error) { return 0, nil }
func (m *MockServerController) GetToolApproval(_, _ string) (*storage.ToolApprovalRecord, error) {
return nil, nil
}
Expand Down
4 changes: 4 additions & 0 deletions internal/httpapi/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,10 @@ func (m *baseController) ListToolApprovals(_ string) ([]*storage.ToolApprovalRec
}
func (m *baseController) ApproveTools(_ string, _ []string, _ string) error { return nil }
func (m *baseController) ApproveAllTools(_ string, _ string) (int, error) { return 0, nil }
func (m *baseController) BlockTools(_ string, _ []string, _ string) (int, error) {
return 0, nil
}
func (m *baseController) BlockAllTools(_ string, _ string) (int, error) { return 0, nil }
func (m *baseController) GetToolApproval(_, _ string) (*storage.ToolApprovalRecord, error) {
return nil, nil
}
Expand Down
71 changes: 71 additions & 0 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ type ServerController interface {
ListToolApprovals(serverName string) ([]*storage.ToolApprovalRecord, error)
ApproveTools(serverName string, toolNames []string, approvedBy string) error
ApproveAllTools(serverName string, approvedBy string) (int, error)
// BlockTools / BlockAllTools atomically approve+disable tools (MCP-2198):
// all-or-nothing so a tool is never left approved+enabled.
BlockTools(serverName string, toolNames []string, blockedBy string) (int, error)
BlockAllTools(serverName string, blockedBy string) (int, error)
GetToolApproval(serverName, toolName string) (*storage.ToolApprovalRecord, error)

// Onboarding wizard (Spec 046)
Expand Down Expand Up @@ -634,6 +638,9 @@ func (s *Server) setupRoutes() {

// Tool-level quarantine (Spec 032)
r.Post("/tools/approve", s.handleApproveTools)
// Atomic block = approve+disable (MCP-2198). Server-side so the
// pair is all-or-nothing — a tool is never left approved+enabled.
r.Post("/tools/block", s.handleBlockTools)
r.Post("/tools/{tool}/enabled", s.handleSetToolEnabled)
// Bulk per-tool enable/disable. Mirrors /servers/enable_all
// + /servers/disable_all but scoped to a single server's tools.
Expand Down Expand Up @@ -4665,6 +4672,70 @@ func (s *Server) handleApproveTools(w http.ResponseWriter, r *http.Request) {
})
}

// handleBlockTools handles POST /api/v1/servers/{id}/tools/block
// A block is an atomic approve+disable performed in the runtime so the pair is
// all-or-nothing — a tool is never left approved+enabled. Mirrors
// handleApproveTools: accepts either {"tools":[...]} or {"block_all":true}.
//
// @Summary Block (approve+disable) tools for a server
// @Description Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The "blocked" field counts tools actually blocked.
// @Tags servers
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Security ApiKeyQuery
// @Param id path string true "Server ID or name"
// @Success 200 {object} contracts.SuccessResponse "Block result"
// @Failure 400 {object} contracts.ErrorResponse "Bad request"
// @Failure 500 {object} contracts.ErrorResponse "Internal server error"
// @Router /api/v1/servers/{id}/tools/block [post]
func (s *Server) handleBlockTools(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "id")
if serverID == "" {
s.writeError(w, r, http.StatusBadRequest, "Server ID required")
return
}

var req struct {
Tools []string `json:"tools"`
BlockAll bool `json:"block_all"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, r, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err))
return
}

if req.BlockAll {
count, err := s.controller.BlockAllTools(serverID, "api")
if err != nil {
s.writeError(w, r, http.StatusInternalServerError, fmt.Sprintf("Failed to block tools: %v", err))
return
}
s.writeSuccess(w, map[string]interface{}{
"blocked": count,
"message": fmt.Sprintf("Blocked %d tools for server %s", count, serverID),
})
return
}

if len(req.Tools) == 0 {
s.writeError(w, r, http.StatusBadRequest, "Either 'tools' array or 'block_all: true' required")
return
}

count, err := s.controller.BlockTools(serverID, req.Tools, "api")
if err != nil {
s.writeError(w, r, http.StatusInternalServerError, fmt.Sprintf("Failed to block tools: %v", err))
return
}

s.writeSuccess(w, map[string]interface{}{
"blocked": count,
"tools": req.Tools,
"message": fmt.Sprintf("Blocked %d tools for server %s", count, serverID),
})
}

// handleGetToolDiff handles GET /api/v1/servers/{id}/tools/{tool}/diff
func (s *Server) handleSetToolEnabled(w http.ResponseWriter, r *http.Request) {
authCtx := auth.AuthContextFromContext(r.Context())
Expand Down
119 changes: 119 additions & 0 deletions internal/httpapi/tool_quarantine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ type mockToolQuarantineController struct {
setAllToolsEnabledTo *bool
setAllToolsEnabledFor string
setAllToolsChangedFake int
blockErr error
blockAllErr error
blockedCount int
blockedTools []string
blockedServer string
}

func (m *mockToolQuarantineController) GetCurrentConfig() any {
Expand Down Expand Up @@ -68,6 +73,20 @@ func (m *mockToolQuarantineController) ApproveAllTools(serverName string, approv
return m.approvedCount, m.approveAllErr
}

func (m *mockToolQuarantineController) BlockTools(serverName string, toolNames []string, _ string) (int, error) {
m.blockedServer = serverName
m.blockedTools = toolNames
if m.blockErr != nil {
return 0, m.blockErr
}
return len(toolNames), nil
}

func (m *mockToolQuarantineController) BlockAllTools(serverName string, _ string) (int, error) {
m.blockedServer = serverName
return m.blockedCount, m.blockAllErr
}

func (m *mockToolQuarantineController) GetToolApproval(serverName, toolName string) (*storage.ToolApprovalRecord, error) {
for _, a := range m.approvals {
if a.ServerName == serverName && a.ToolName == toolName {
Expand Down Expand Up @@ -338,6 +357,106 @@ func TestHandleApproveTools_ApproveError(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code)
}

// =============================================================================
// MCP-2198: atomic block (approve+disable) handler tests
// =============================================================================

func TestHandleBlockTools_SpecificTools(t *testing.T) {
ctrl := &mockToolQuarantineController{apiKey: "test-key"}
logger := zap.NewNop().Sugar()
server := NewServer(ctrl, logger, nil)

body := `{"tools": ["create_issue", "list_repos"]}`
req := httptest.NewRequest("POST", "/api/v1/servers/github/tools/block", bytes.NewBufferString(body))
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()

server.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "github", ctrl.blockedServer)
assert.Equal(t, []string{"create_issue", "list_repos"}, ctrl.blockedTools)

var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
data := resp["data"].(map[string]interface{})
assert.Equal(t, float64(2), data["blocked"])
}

func TestHandleBlockTools_BlockAll(t *testing.T) {
ctrl := &mockToolQuarantineController{
apiKey: "test-key",
blockedCount: 5,
}
logger := zap.NewNop().Sugar()
server := NewServer(ctrl, logger, nil)

body := `{"block_all": true}`
req := httptest.NewRequest("POST", "/api/v1/servers/github/tools/block", bytes.NewBufferString(body))
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()

server.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "github", ctrl.blockedServer)

var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
data := resp["data"].(map[string]interface{})
assert.Equal(t, float64(5), data["blocked"])
}

func TestHandleBlockTools_EmptyToolsAndNoBlockAll(t *testing.T) {
ctrl := &mockToolQuarantineController{apiKey: "test-key"}
logger := zap.NewNop().Sugar()
server := NewServer(ctrl, logger, nil)

body := `{"tools": []}`
req := httptest.NewRequest("POST", "/api/v1/servers/github/tools/block", bytes.NewBufferString(body))
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()

server.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
}

func TestHandleBlockTools_InvalidJSON(t *testing.T) {
ctrl := &mockToolQuarantineController{apiKey: "test-key"}
logger := zap.NewNop().Sugar()
server := NewServer(ctrl, logger, nil)

body := `{invalid`
req := httptest.NewRequest("POST", "/api/v1/servers/github/tools/block", bytes.NewBufferString(body))
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()

server.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
}

func TestHandleBlockTools_BlockError(t *testing.T) {
ctrl := &mockToolQuarantineController{
apiKey: "test-key",
blockErr: fmt.Errorf("server not found"),
}
logger := zap.NewNop().Sugar()
server := NewServer(ctrl, logger, nil)

body := `{"tools": ["create_issue"]}`
req := httptest.NewRequest("POST", "/api/v1/servers/github/tools/block", bytes.NewBufferString(body))
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()

server.ServeHTTP(w, req)

assert.Equal(t, http.StatusInternalServerError, w.Code)
}

func TestHandleGetToolDiff_ChangedTool(t *testing.T) {
ctrl := &mockToolQuarantineController{
apiKey: "test-key",
Expand Down
Loading
Loading