feat: finalize dc dual-channel security rollout

This commit is contained in:
kory
2026-10-02 13:23:25 +08:00
parent a5620cb9c4
commit 192969d7f6
11 changed files with 1964 additions and 85 deletions
+97 -1
View File
@@ -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
+50 -2
View File
@@ -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)
}
}