Skip to content

Commit 65a54b9

Browse files
committed
fix(runner): 完善工具执行安全校验与能力令牌闭环
- Gateway 签发 CapabilityToken 并随工具请求下发给 Runner - Runner 验证 Token 签名/TTL/工具白名单 + 路径 allowlist 校验 - Token 从 URL query 迁移至 Authorization Header - 添加 WebSocket 并发写保护、工具注册、Shell 自动检测 - 路径判定排除 URL 等非路径字符串,相对路径基于 workdir 解析
1 parent d824327 commit 65a54b9

6 files changed

Lines changed: 217 additions & 32 deletions

File tree

internal/gateway/protocol/runner.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package protocol
22

3-
import "encoding/json"
3+
import (
4+
"encoding/json"
5+
6+
"neo-code/internal/security"
7+
)
48

59
const (
610
// MethodGatewayRegisterRunner 表示 runner 向网关注册。
@@ -32,10 +36,11 @@ type ExecuteToolResultParams struct {
3236

3337
// ToolRequestParams 是网关推送给 runner 的工具执行请求。
3438
type ToolRequestParams struct {
35-
RequestID string `json:"request_id"`
36-
SessionID string `json:"session_id"`
37-
RunID string `json:"run_id"`
38-
ToolCallID string `json:"tool_call_id"`
39-
ToolName string `json:"tool_name"`
40-
Arguments json.RawMessage `json:"arguments"`
39+
RequestID string `json:"request_id"`
40+
SessionID string `json:"session_id"`
41+
RunID string `json:"run_id"`
42+
ToolCallID string `json:"tool_call_id"`
43+
ToolName string `json:"tool_name"`
44+
Arguments json.RawMessage `json:"arguments"`
45+
CapabilityToken *security.CapabilityToken `json:"capability_token,omitempty"`
4146
}

internal/gateway/runner_tool.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,18 +87,37 @@ func (m *RunnerToolManager) DispatchToolRequest(ctx context.Context, sessionID s
8787
m.pending[requestID] = pending
8888
m.mu.Unlock()
8989

90+
// 签发 capability token(如果 signer 已配置)
91+
var capToken *security.CapabilityToken
92+
if m.capabilitySigner != nil {
93+
workdir := ""
94+
if record, ok := m.registry.Record(connectionID); ok {
95+
workdir = record.Workdir
96+
}
97+
signed, err := m.NewCapabilityToken(sessionID, runID, toolName, workdir)
98+
if err != nil {
99+
m.logger.Printf("failed to sign capability token: %v", err)
100+
} else if signed != nil {
101+
capToken = signed
102+
}
103+
}
104+
90105
// 构建通知并推送到 runner 连接
106+
params := map[string]any{
107+
"request_id": requestID,
108+
"session_id": sessionID,
109+
"run_id": runID,
110+
"tool_call_id": toolCallID,
111+
"tool_name": toolName,
112+
"arguments": arguments,
113+
}
114+
if capToken != nil {
115+
params["capability_token"] = capToken
116+
}
91117
notification := map[string]any{
92118
"jsonrpc": "2.0",
93119
"method": protocol.MethodGatewayToolRequest,
94-
"params": map[string]any{
95-
"request_id": requestID,
96-
"session_id": sessionID,
97-
"run_id": runID,
98-
"tool_call_id": toolCallID,
99-
"tool_name": toolName,
100-
"arguments": arguments,
101-
},
120+
"params": params,
102121
}
103122
if !m.relay.SendJSONRPCPayload(connectionID, notification) {
104123
m.mu.Lock()

internal/runner/capability.go

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package runner
22

33
import (
4+
"encoding/json"
45
"path/filepath"
56
goruntime "runtime"
67
"strings"
8+
"time"
9+
10+
"neo-code/internal/security"
711
)
812

913
// CapSigner 在 runner 端负责验证 capability token 和 workspace 边界。
1014
type CapSigner struct {
15+
capVerifier *security.CapabilitySigner
1116
workdirAllowlist []string
1217
}
1318

@@ -18,14 +23,99 @@ func NewCapSigner(workdirAllowlist []string) *CapSigner {
1823
}
1924
}
2025

26+
// SetCapVerifier 设置用于验证 capability token 签名的验签器。
27+
func (s *CapSigner) SetCapVerifier(verifier *security.CapabilitySigner) {
28+
s.capVerifier = verifier
29+
}
30+
2131
// VerifyToolRequest 验证工具执行请求是否被允许。
2232
// 检查:
23-
// 1. 工具名是否被允许(默认所有工具允许,除非有 capability token)
33+
// 1. CapabilityToken 签名、TTL、工具白名单(如果提供了 token)
2434
// 2. 路径是否在工作区 allowlist 内
2535
func (s *CapSigner) VerifyToolRequest(req ToolExecutionRequest, workdir string) error {
26-
_ = req // reserved for future capability token validation
27-
_ = workdir
28-
return nil // no additional checks for MVP; capability token validation added later
36+
// 如果提供了 capability token,验证其签名和权限
37+
if req.CapabilityToken != nil {
38+
if s.capVerifier != nil {
39+
if err := s.capVerifier.Verify(*req.CapabilityToken); err != nil {
40+
return ErrCapabilitySignatureInvalid
41+
}
42+
}
43+
if err := req.CapabilityToken.ValidateAt(time.Now()); err != nil {
44+
return ErrCapabilityTokenExpired
45+
}
46+
if !isToolAllowed(req.CapabilityToken.AllowedTools, req.ToolName) {
47+
return ErrCapabilityToolNotAllowed
48+
}
49+
}
50+
51+
// 验证路径是否在 allowlist 内
52+
if req.Arguments != nil {
53+
if err := s.verifyPathsInArgs(req.Arguments, workdir); err != nil {
54+
return err
55+
}
56+
}
57+
58+
return nil
59+
}
60+
61+
// verifyPathsInArgs 检查参数中的路径是否在 allowlist 范围内。
62+
func (s *CapSigner) verifyPathsInArgs(args json.RawMessage, workdir string) error {
63+
if len(s.workdirAllowlist) == 0 {
64+
return nil
65+
}
66+
var m map[string]any
67+
if err := json.Unmarshal(args, &m); err != nil {
68+
return nil
69+
}
70+
for _, v := range m {
71+
str, ok := v.(string)
72+
if !ok {
73+
continue
74+
}
75+
if looksLikePath(str) {
76+
resolved := resolvePath(str, workdir)
77+
if err := s.VerifyPath(resolved); err != nil {
78+
return err
79+
}
80+
}
81+
}
82+
return nil
83+
}
84+
85+
// looksLikePath 判断字符串是否看起来像文件路径。
86+
func looksLikePath(s string) bool {
87+
if strings.Contains(s, "://") {
88+
return false
89+
}
90+
return strings.Contains(s, "/") || strings.Contains(s, "\\") ||
91+
strings.HasPrefix(s, ".") || filepath.IsAbs(s)
92+
}
93+
94+
// resolvePath 将 target 基于 workdir 解析为绝对路径,用于 allowlist 比较。
95+
func resolvePath(target string, workdir string) string {
96+
trimmed := strings.TrimSpace(target)
97+
if trimmed == "" {
98+
return ""
99+
}
100+
if filepath.IsAbs(trimmed) {
101+
return trimmed
102+
}
103+
base := strings.TrimSpace(workdir)
104+
if base != "" {
105+
return filepath.Join(base, trimmed)
106+
}
107+
return trimmed
108+
}
109+
110+
// isToolAllowed 判断工具名是否在 token 允许列表中。
111+
func isToolAllowed(allowedTools []string, toolName string) bool {
112+
normalized := strings.ToLower(strings.TrimSpace(toolName))
113+
for _, allowed := range allowedTools {
114+
if strings.ToLower(strings.TrimSpace(allowed)) == normalized {
115+
return true
116+
}
117+
}
118+
return false
29119
}
30120

31121
// VerifyPath 验证目标路径是否在 allowlist 范围内。

internal/runner/runner.go

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@ import (
99
"math/rand"
1010
"net/http"
1111
"os"
12+
goruntime "runtime"
1213
"strings"
1314
"sync"
1415
"time"
1516

1617
"github.com/gorilla/websocket"
1718

1819
"neo-code/internal/tools"
20+
"neo-code/internal/tools/bash"
21+
diagnosetool "neo-code/internal/tools/diagnose"
22+
"neo-code/internal/tools/filesystem"
23+
"neo-code/internal/tools/todo"
24+
"neo-code/internal/tools/webfetch"
1925
)
2026

2127
// Runner 是本地执行守护进程,主动连接云端 Gateway,接收工具执行请求。
@@ -26,6 +32,7 @@ type Runner struct {
2632
capSigner *CapSigner
2733

2834
mu sync.Mutex
35+
writeMu sync.Mutex // 保护 WebSocket 并发写
2936
running bool
3037
cancel context.CancelFunc
3138
}
@@ -38,6 +45,7 @@ type Config struct {
3845
Token string
3946
Workdir string
4047
WorkdirAllowlist []string
48+
Shell string // 用于 bash 工具,空值自动检测
4149
HeartbeatInterval time.Duration
4250
ReconnectBackoffMin time.Duration
4351
ReconnectBackoffMax time.Duration
@@ -71,7 +79,38 @@ func New(cfg Config) (*Runner, error) {
7179
logger = log.New(os.Stderr, "runner: ", log.LstdFlags)
7280
}
7381

82+
shell := cfg.Shell
83+
if shell == "" {
84+
if goruntime.GOOS == "windows" {
85+
shell = "cmd"
86+
} else {
87+
shell = "bash"
88+
}
89+
}
90+
workdir := cfg.Workdir
91+
if workdir == "" {
92+
var err error
93+
workdir, err = os.Getwd()
94+
if err != nil {
95+
return nil, fmt.Errorf("runner: get workdir: %w", err)
96+
}
97+
}
98+
7499
toolMgr := tools.NewRegistry()
100+
toolMgr.Register(filesystem.New(workdir))
101+
toolMgr.Register(filesystem.NewWrite(workdir))
102+
toolMgr.Register(filesystem.NewGrep(workdir))
103+
toolMgr.Register(filesystem.NewGlob(workdir))
104+
toolMgr.Register(filesystem.NewEdit(workdir))
105+
toolMgr.Register(filesystem.NewMove(workdir))
106+
toolMgr.Register(filesystem.NewCopy(workdir))
107+
toolMgr.Register(filesystem.NewDelete(workdir))
108+
toolMgr.Register(filesystem.NewCreateDir(workdir))
109+
toolMgr.Register(filesystem.NewRemoveDir(workdir))
110+
toolMgr.Register(bash.New(workdir, shell, cfg.RequestTimeout))
111+
toolMgr.Register(webfetch.New(webfetch.Config{Timeout: cfg.RequestTimeout}))
112+
toolMgr.Register(diagnosetool.New())
113+
toolMgr.Register(todo.New())
75114

76115
capSigner := NewCapSigner(cfg.WorkdirAllowlist)
77116

@@ -130,12 +169,12 @@ func (r *Runner) Run(ctx context.Context) error {
130169

131170
func (r *Runner) connectAndServe(ctx context.Context) error {
132171
url := fmt.Sprintf("ws://%s/ws", r.cfg.GatewayAddress)
133-
if r.cfg.Token != "" {
134-
url += "?token=" + r.cfg.Token
135-
}
136172

137173
header := http.Header{}
138174
header.Set("X-Runner-ID", r.cfg.RunnerID)
175+
if r.cfg.Token != "" {
176+
header.Set("Authorization", "Bearer "+r.cfg.Token)
177+
}
139178

140179
dialer := websocket.Dialer{
141180
HandshakeTimeout: r.cfg.RequestTimeout,
@@ -149,7 +188,7 @@ func (r *Runner) connectAndServe(ctx context.Context) error {
149188
}
150189
defer conn.Close()
151190

152-
r.logger.Printf("connected to gateway at %s", url)
191+
r.logger.Printf("connected to gateway at %s (runner=%s)", r.cfg.GatewayAddress, r.cfg.RunnerID)
153192

154193
// 认证
155194
if err := r.sendRequest(conn, "gateway.authenticate", map[string]string{
@@ -224,6 +263,24 @@ func (r *Runner) handleToolRequest(ctx context.Context, conn *websocket.Conn, ms
224263

225264
r.logger.Printf("executing tool: %s (request_id=%s)", req.ToolName, req.RequestID)
226265

266+
// 验证 capability token 和路径边界
267+
if err := r.capSigner.VerifyToolRequest(req, r.cfg.Workdir); err != nil {
268+
r.logger.Printf("tool request denied: %v", err)
269+
resultParams := map[string]any{
270+
"request_id": req.RequestID,
271+
"session_id": req.SessionID,
272+
"run_id": req.RunID,
273+
"runner_id": r.cfg.RunnerID,
274+
"tool_call_id": req.ToolCallID,
275+
"content": fmt.Sprintf("tool request denied: %v", err),
276+
"is_error": true,
277+
}
278+
if sendErr := r.sendRequest(conn, "gateway.executeToolResult", resultParams); sendErr != nil {
279+
r.logger.Printf("failed to send denied result: %v", sendErr)
280+
}
281+
return
282+
}
283+
227284
// 执行工具
228285
execCtx, cancel := context.WithTimeout(ctx, r.cfg.RequestTimeout)
229286
defer cancel()
@@ -268,6 +325,8 @@ func (r *Runner) handlePing(conn *websocket.Conn, msg map[string]any) {
268325
"result": "pong",
269326
}
270327
data, _ := json.Marshal(response)
328+
r.writeMu.Lock()
329+
defer r.writeMu.Unlock()
271330
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
272331
r.logger.Printf("failed to send pong: %v", err)
273332
}
@@ -304,6 +363,8 @@ func (r *Runner) sendRequest(conn *websocket.Conn, method string, params any) er
304363
return fmt.Errorf("marshal request: %w", err)
305364
}
306365

366+
r.writeMu.Lock()
367+
defer r.writeMu.Unlock()
307368
if err := conn.SetWriteDeadline(time.Now().Add(r.cfg.RequestTimeout)); err != nil {
308369
return fmt.Errorf("set write deadline: %w", err)
309370
}

internal/runner/types.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/json"
55
"errors"
66
"time"
7+
8+
"neo-code/internal/security"
79
)
810

911
var (
@@ -23,12 +25,13 @@ var (
2325

2426
// ToolExecutionRequest 表示从网关收到的工具执行请求。
2527
type ToolExecutionRequest struct {
26-
RequestID string `json:"request_id"`
27-
SessionID string `json:"session_id"`
28-
RunID string `json:"run_id"`
29-
ToolCallID string `json:"tool_call_id"`
30-
ToolName string `json:"tool_name"`
31-
Arguments json.RawMessage `json:"arguments"`
28+
RequestID string `json:"request_id"`
29+
SessionID string `json:"session_id"`
30+
RunID string `json:"run_id"`
31+
ToolCallID string `json:"tool_call_id"`
32+
ToolName string `json:"tool_name"`
33+
Arguments json.RawMessage `json:"arguments"`
34+
CapabilityToken *security.CapabilityToken `json:"capability_token,omitempty"`
3235
}
3336

3437
// ToolExecutionResult 表示工具执行结果。

www/guide/feishu-remote-setup.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,17 @@ Runner 断连后会自动重连,采用指数退避 + 随机抖动策略:
334334

335335
### 8.4 安全边界
336336

337-
- Runner 只执行 Gateway 签发并签名的工具请求(CapabilityToken HMAC-SHA256 校验)
338-
- Token 有过期时间(TTL),过期请求会被拒绝
337+
当前已实现:
338+
339+
- Runner 验证 Gateway 签发的 CapabilityToken(HMAC-SHA256 签名校验、TTL 过期检查、工具白名单)
340+
- Token 有过期时间(5 分钟 TTL),过期请求会被拒绝
339341
- 支持配置工作区路径白名单(`WorkdirAllowlist`),拒绝越界路径访问
340-
- 所有工具在 Runner 本机执行,结果通过 Gateway 加密回传
342+
- 所有工具在 Runner 本机执行
343+
344+
传输安全注意事项:
345+
346+
- Runner 与 Gateway 之间当前使用明文 WebSocket(`ws://`),建议仅在受信任的本地网络中使用,或通过 SSH 隧道 / VPN 加固传输层
347+
- TLS 加密传输(`wss://`)计划在后续版本支持
341348

342349
### 8.5 错误提示
343350

0 commit comments

Comments
 (0)