feat: finalize dc dual-channel security rollout
This commit is contained in:
+97
-1
@@ -74,6 +74,14 @@ type ActionBackend interface {
|
||||
ExecCommand(ctx context.Context, clientIP, device, command, workdir string, timeout int) (any, error)
|
||||
ReadFile(ctx context.Context, clientIP, device, path string, offset, limit int64) (any, error)
|
||||
WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error)
|
||||
EditBlock(ctx context.Context, clientIP, device, path, oldText, newText string) (any, error)
|
||||
}
|
||||
|
||||
type ExtendedBackend interface {
|
||||
ReadMultipleFiles(ctx context.Context, clientIP, device string, paths []string, limit int64) (any, error)
|
||||
CreateDirectory(ctx context.Context, clientIP, device, path string) (any, error)
|
||||
MoveFile(ctx context.Context, clientIP, device, source, destination string) (any, error)
|
||||
ListProcesses(ctx context.Context, clientIP, device string) (any, error)
|
||||
}
|
||||
|
||||
type AgentMCPBackend interface {
|
||||
@@ -81,6 +89,10 @@ type AgentMCPBackend interface {
|
||||
CallAgentMCP(ctx context.Context, clientIP, device, server, tool string, args any) (any, error)
|
||||
}
|
||||
|
||||
type SecurityGate interface {
|
||||
AuthorizeMCP(device, action, clientIP string) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Backend ActionBackend
|
||||
Auth *auth.Manager
|
||||
@@ -264,8 +276,16 @@ func (s *Server) handleRequest(ctx context.Context, r *http.Request, req JSONRPC
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{Name: "read_multiple_files", Description: "一次读取多个文件", InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标设备"}, "paths": {Type: "array", Description: "文件路径数组"}, "limit": {Type: "integer", Description: "单文件最大字节数"}}, Required: []string{"paths"}}},
|
||||
{Name: "create_directory", Description: "创建目录", InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标设备"}, "path": {Type: "string", Description: "目录路径"}}, Required: []string{"path"}}},
|
||||
{Name: "move_file", Description: "移动或重命名文件", InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标设备"}, "source": {Type: "string", Description: "源路径"}, "destination": {Type: "string", Description: "目标路径"}}, Required: []string{"source", "destination"}}},
|
||||
{Name: "list_processes", Description: "列出受控节点当前进程", InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标设备"}}}},
|
||||
{
|
||||
Name: "edit_block",
|
||||
Description: "对指定文件执行唯一文本块的原子替换;old_text 必须恰好匹配一次",
|
||||
InputSchema: InputSchema{Type: "object", Properties: map[string]PropertyDef{"device": {Type: "string", Description: "目标节点名称"}, "path": {Type: "string", Description: "目标文件路径"}, "old_text": {Type: "string", Description: "必须唯一匹配的原文本块"}, "new_text": {Type: "string", Description: "替换后的文本块"}}, Required: []string{"path", "old_text", "new_text"}},
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "向指定受控节点创建或覆写文件",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
@@ -369,6 +389,72 @@ func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolP
|
||||
}
|
||||
return res, false
|
||||
|
||||
case "read_multiple_files":
|
||||
var a struct {
|
||||
Device string `json:"device"`
|
||||
Paths []string `json:"paths"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &a)
|
||||
b, ok := s.Backend.(ExtendedBackend)
|
||||
if !ok {
|
||||
return map[string]string{"error": "extended tools unavailable"}, true
|
||||
}
|
||||
res, err := b.ReadMultipleFiles(ctx, clientIP, a.Device, a.Paths, a.Limit)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
case "create_directory":
|
||||
var a struct{ Device, Path string }
|
||||
_ = json.Unmarshal(params.Arguments, &a)
|
||||
b, ok := s.Backend.(ExtendedBackend)
|
||||
if !ok {
|
||||
return map[string]string{"error": "extended tools unavailable"}, true
|
||||
}
|
||||
res, err := b.CreateDirectory(ctx, clientIP, a.Device, a.Path)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
case "move_file":
|
||||
var a struct{ Device, Source, Destination string }
|
||||
_ = json.Unmarshal(params.Arguments, &a)
|
||||
b, ok := s.Backend.(ExtendedBackend)
|
||||
if !ok {
|
||||
return map[string]string{"error": "extended tools unavailable"}, true
|
||||
}
|
||||
res, err := b.MoveFile(ctx, clientIP, a.Device, a.Source, a.Destination)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
case "list_processes":
|
||||
var a struct {
|
||||
Device string `json:"device"`
|
||||
}
|
||||
_ = json.Unmarshal(params.Arguments, &a)
|
||||
b, ok := s.Backend.(ExtendedBackend)
|
||||
if !ok {
|
||||
return map[string]string{"error": "extended tools unavailable"}, true
|
||||
}
|
||||
res, err := b.ListProcesses(ctx, clientIP, a.Device)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
case "edit_block":
|
||||
var args struct{ Device, Path, OldText, NewText string }
|
||||
_ = json.Unmarshal(params.Arguments, &args)
|
||||
if args.Path == "" || args.OldText == "" {
|
||||
return map[string]string{"error": "path and old_text are required"}, true
|
||||
}
|
||||
res, err := s.Backend.EditBlock(ctx, clientIP, args.Device, args.Path, args.OldText, args.NewText)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
return res, false
|
||||
|
||||
case "write_file":
|
||||
var args struct {
|
||||
Device string `json:"device"`
|
||||
@@ -394,6 +480,11 @@ func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolP
|
||||
if !ok {
|
||||
return map[string]string{"error": "agent MCP unavailable"}, true
|
||||
}
|
||||
if gate, ok := s.Backend.(SecurityGate); ok {
|
||||
if err := gate.AuthorizeMCP(args.Device, "read", clientIP); err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
}
|
||||
res, err := b.ListAgentMCP(ctx, args.Device)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
@@ -416,6 +507,11 @@ func (s *Server) callTool(ctx context.Context, clientIP string, params CallToolP
|
||||
if len(args.Arguments) > 0 {
|
||||
_ = json.Unmarshal(args.Arguments, &av)
|
||||
}
|
||||
if gate, ok := s.Backend.(SecurityGate); ok {
|
||||
if err := gate.AuthorizeMCP(args.Device, "mcp", clientIP); err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
}
|
||||
}
|
||||
res, err := b.CallAgentMCP(ctx, clientIP, args.Device, args.Server, args.Tool, av)
|
||||
if err != nil {
|
||||
return map[string]string{"error": err.Error()}, true
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -24,6 +25,9 @@ func (m *mockBackend) ReadFile(ctx context.Context, clientIP, device, path strin
|
||||
func (m *mockBackend) WriteFile(ctx context.Context, clientIP, device, path, content string) (any, error) {
|
||||
return map[string]any{"device": device, "path": path, "bytes": len(content)}, nil
|
||||
}
|
||||
func (m *mockBackend) EditBlock(ctx context.Context, clientIP, device, path, oldText, newText string) (any, error) {
|
||||
return map[string]any{"device": device, "path": path, "bytes_written": len(newText)}, nil
|
||||
}
|
||||
|
||||
func TestMCPInitializeAndListTools(t *testing.T) {
|
||||
srv := NewServer(&mockBackend{}, nil)
|
||||
@@ -75,8 +79,8 @@ func TestMCPInitializeAndListTools(t *testing.T) {
|
||||
t.Fatalf("expected map result")
|
||||
}
|
||||
tools, ok := resMap["tools"].([]any)
|
||||
if !ok || len(tools) != 6 {
|
||||
t.Fatalf("expected 6 tools, got %d", len(tools))
|
||||
if !ok || len(tools) != 11 {
|
||||
t.Fatalf("expected 11 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,3 +114,47 @@ func TestMCPCallTool(t *testing.T) {
|
||||
t.Fatalf("expected stdout in result, got %s", contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
type gatedMockBackend struct {
|
||||
mockBackend
|
||||
deniedAction string
|
||||
}
|
||||
|
||||
func (g *gatedMockBackend) AuthorizeMCP(device, action, clientIP string) error {
|
||||
if action == g.deniedAction {
|
||||
return fmt.Errorf("denied %s", action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gatedMockBackend) ListAgentMCP(ctx context.Context, device string) (any, error) {
|
||||
return map[string]any{"device": device}, nil
|
||||
}
|
||||
|
||||
func (g *gatedMockBackend) CallAgentMCP(ctx context.Context, clientIP, device, server, tool string, args any) (any, error) {
|
||||
return map[string]any{"device": device, "server": server, "tool": tool}, nil
|
||||
}
|
||||
|
||||
func TestMCPAgentCallUsesSecurityGate(t *testing.T) {
|
||||
backend := &gatedMockBackend{deniedAction: "mcp"}
|
||||
srv := NewServer(backend, nil)
|
||||
params := json.RawMessage("{\"name\":\"call_agent_mcp\",\"arguments\":{\"device\":\"node1\",\"server\":\"s\",\"tool\":\"t\"}}")
|
||||
req := JSONRPCRequest{JSONRPC: "2.0", ID: 10, Method: "tools/call", Params: params}
|
||||
resp := srv.HandleDirectRequest(context.Background(), req)
|
||||
result, ok := resp.Result.(CallToolResult)
|
||||
if !ok || !result.IsError || !strings.Contains(result.Content[0].Text, "denied mcp") {
|
||||
t.Fatalf("expected MCP denial, got %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPListAgentUsesReadGate(t *testing.T) {
|
||||
backend := &gatedMockBackend{deniedAction: "read"}
|
||||
srv := NewServer(backend, nil)
|
||||
params := json.RawMessage("{\"name\":\"list_agent_mcp\",\"arguments\":{\"device\":\"node1\"}}")
|
||||
req := JSONRPCRequest{JSONRPC: "2.0", ID: 11, Method: "tools/call", Params: params}
|
||||
resp := srv.HandleDirectRequest(context.Background(), req)
|
||||
result, ok := resp.Result.(CallToolResult)
|
||||
if !ok || !result.IsError || !strings.Contains(result.Content[0].Text, "denied read") {
|
||||
t.Fatalf("expected MCP list denial, got %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user