You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -68,7 +68,10 @@ func sliceLines(s string, line, limit *int) string {
|
||||
}
|
||||
end := len(lines)
|
||||
if limit != nil && *limit >= 0 {
|
||||
if start+*limit < end {
|
||||
// overflow-safe:用剩余行数为上限做钳制,避免 start+*limit 在 *limit 很大
|
||||
// (如 math.MaxInt)时整数溢出为负,绕过下界判断后导致 lines[start:end] panic。
|
||||
remaining := len(lines) - start
|
||||
if *limit < remaining {
|
||||
end = start + *limit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -81,6 +82,37 @@ func TestFsRead_LineLimit(t *testing.T) {
|
||||
assert.Equal(t, "L1\nL2", out.Content)
|
||||
}
|
||||
|
||||
// TestFsRead_HugeLimit_NoOverflow 锁定 bug #2:limit 为 math.MaxInt 时
|
||||
// start+limit 会整数溢出为负,旧逻辑下 lines[start:end] 会 panic。修复后应安全
|
||||
// 钳制到文件末尾,返回从 line 起的剩余内容。
|
||||
func TestFsRead_HugeLimit_NoOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmp, "f.txt"),
|
||||
[]byte("L0\nL1\nL2\nL3\n"), 0o644))
|
||||
|
||||
h := handlers.NewFsReadHandler()
|
||||
sess := handlers.SessionContext{CwdPath: tmp}
|
||||
line := 1
|
||||
limit := math.MaxInt
|
||||
params := mustJSON(t, map[string]any{
|
||||
"sessionId": "x", "path": filepath.Join(tmp, "f.txt"),
|
||||
"line": line, "limit": limit,
|
||||
})
|
||||
|
||||
var res handlers.FsResult
|
||||
require.NotPanics(t, func() {
|
||||
res = h.Handle(context.Background(), sess, params)
|
||||
})
|
||||
require.Nil(t, res.Err)
|
||||
var out struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.OK, &out))
|
||||
// 从 line=1 到末尾(split 出 5 个元素:L0 L1 L2 L3 "")。
|
||||
assert.Equal(t, "L1\nL2\nL3\n", out.Content)
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
|
||||
@@ -314,8 +314,13 @@ func resolvedEnvelope(reqID uuid.UUID, status PermissionStatus, chosen string) *
|
||||
}
|
||||
}
|
||||
|
||||
// extractToolName 尝试从 toolCall JSON 中提取工具标识。多个 agent 实现的字段名不同,
|
||||
// 依次尝试 name/toolName/kind/title;都取不到则返回空(保守走人工审批)。
|
||||
// extractToolName 尝试从 toolCall JSON 中提取真实工具标识。
|
||||
//
|
||||
// 安全约束(自动放行依据):只接受真实工具名字段(name / toolName)。绝不回退到
|
||||
// ACP toolCall 的 'kind'(粗粒度枚举 read/edit/execute…)或 'title'(自由文本)——
|
||||
// 二者都由不可信 agent 自行填写,agent 可把一个 execute/shell 调用标成 kind='read'
|
||||
// 或冒用白名单里的 title 来绕过人工审批闸门。取不到真实名时返回空串,使 allowlistHit
|
||||
// 一律不放行(保守走人工审批),同时保留空名默认拒绝的语义。
|
||||
func extractToolName(toolCall json.RawMessage) string {
|
||||
if len(toolCall) == 0 {
|
||||
return ""
|
||||
@@ -324,7 +329,7 @@ func extractToolName(toolCall json.RawMessage) string {
|
||||
if err := json.Unmarshal(toolCall, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, k := range []string{"name", "toolName", "kind", "title"} {
|
||||
for _, k := range []string{"name", "toolName"} {
|
||||
if v, ok := m[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package acp
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAllowlistHit 覆盖白名单匹配的边界:空 toolName 一律不放行(含 "*"),
|
||||
// 白名单中的空串条目不误匹配。
|
||||
@@ -26,3 +29,44 @@ func TestAllowlistHit(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolName_IgnoresAgentAssertedKindTitle 锁定安全约束:extractToolName
|
||||
// 只信任真实工具名(name / toolName),绝不回退到 agent 自填的 kind / title。否则
|
||||
// agent 可把 execute/shell 调用伪装成 kind='read' 或冒用白名单 title 绕过人工审批。
|
||||
func TestExtractToolName_IgnoresAgentAssertedKindTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
toolCall string
|
||||
want string
|
||||
}{
|
||||
{"真实 name", `{"name":"shell.exec"}`, "shell.exec"},
|
||||
{"真实 toolName", `{"toolName":"shell.exec"}`, "shell.exec"},
|
||||
{"仅 kind 不取", `{"kind":"read"}`, ""},
|
||||
{"仅 title 不取", `{"title":"Read file"}`, ""},
|
||||
{"kind+title 都不取", `{"kind":"execute","title":"safe.tool"}`, ""},
|
||||
{"name 优先于 kind/title", `{"name":"real.tool","kind":"read","title":"x"}`, "real.tool"},
|
||||
{"空 toolCall", ``, ""},
|
||||
{"无可识别字段", `{"unknownField":1}`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := extractToolName(json.RawMessage(c.toolCall))
|
||||
if got != c.want {
|
||||
t.Fatalf("extractToolName(%q) = %q, want %q", c.toolCall, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowlist_DoesNotHonorAgentKind 端到端验证 bug #3:即使 allowlist 含 'read'
|
||||
// 这类 kind 值,agent 把 execute 调用标成 kind='read' 也不会自动放行(extractToolName
|
||||
// 返回空 → allowlistHit 为 false)。
|
||||
func TestAllowlist_DoesNotHonorAgentKind(t *testing.T) {
|
||||
toolName := extractToolName(json.RawMessage(`{"kind":"read","title":"safe"}`))
|
||||
if toolName != "" {
|
||||
t.Fatalf("kind/title 不应被当作工具名, got %q", toolName)
|
||||
}
|
||||
if allowlistHit([]string{"read", "safe", "*"}, toolName) {
|
||||
t.Fatal("kind/title 伪装的调用不应命中白名单(含通配符)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,21 @@ func (r *Relay) reader(ctx context.Context, sess handlers.SessionContext) {
|
||||
}
|
||||
|
||||
// handleAgentRequest dispatches an agent → server request to the appropriate handler.
|
||||
// 在独立 goroutine 中运行,故 defer recover 兜底任何 handler panic:记录日志并回送
|
||||
// JSON-RPC internal error,避免 agent 构造的请求 panic 掉整个进程(DoS)。
|
||||
func (r *Relay) handleAgentRequest(ctx context.Context, sess handlers.SessionContext, req *Message) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
r.log.Error("acp.relay.handle_request_panic",
|
||||
"session_id", r.sessionID, "method", req.Method, "panic", fmt.Sprintf("%v", rec))
|
||||
resp := NewResponseErr(req.ID, JSONRPCInternalError, "internal error")
|
||||
select {
|
||||
case r.serverInitiated <- resp:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var resp *Message
|
||||
switch req.Method {
|
||||
case "fs/read_text_file":
|
||||
|
||||
@@ -108,6 +108,12 @@ type Process struct {
|
||||
StartedAt time.Time
|
||||
KilledByUs atomic.Bool // true → exited,false → crashed
|
||||
done chan struct{} // monitor 退出时 close
|
||||
|
||||
// relayCancel 取消 relay 的会话级 context。该 context 由 Spawn 用
|
||||
// context.Background() 派生,独立于调用方的 spawn ctx(spawn ctx 在 Spawn
|
||||
// 返回后立即取消),故 relay 的 reader/writer/persist/permission Decide 在整个
|
||||
// 会话生命周期内都使用未取消的 ctx。onExit 调用 relayCancel 完成 relay teardown。
|
||||
relayCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// stderrRing 是固定行数 ring buffer,monitor 退出时取 tail 写 last_error。
|
||||
@@ -237,6 +243,12 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
s.log,
|
||||
)
|
||||
|
||||
// relay 的会话级 context:独立于调用方的 spawn ctx(生产调用方在 Spawn 返回后
|
||||
// 立即 cancel spawn ctx)。relay 的 reader/writer/persist/permission Decide 必须
|
||||
// 在整个会话生命周期内使用未取消的 ctx,否则 prompt 被丢、事件停止持久化、权限
|
||||
// 审批被强制拒绝。teardown 由 onExit 调 relayCancel + Relay.Close(关 r.done)完成。
|
||||
relayCtx, relayCancel := context.WithCancel(context.Background())
|
||||
|
||||
proc := &Process{
|
||||
SessionID: sess.ID,
|
||||
WorkspaceID: sess.WorkspaceID,
|
||||
@@ -251,6 +263,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
StderrBuf: newStderrRing(s.cfg.StderrBufferLines),
|
||||
StartedAt: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
relayCancel: relayCancel,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -259,7 +272,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
|
||||
go s.drainStderr(proc)
|
||||
go s.monitorProcess(proc)
|
||||
go relay.Run(ctx, handlers.SessionContext{
|
||||
go relay.Run(relayCtx, handlers.SessionContext{
|
||||
SessionID: sess.ID,
|
||||
WorkspaceID: sess.WorkspaceID,
|
||||
UserID: sess.UserID,
|
||||
@@ -268,6 +281,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
ToolAllowlist: kind.ToolAllowlist,
|
||||
})
|
||||
|
||||
// 握手仍走调用方的 spawn ctx,保留 SpawnTimeout 截止语义。
|
||||
if err := s.handshake(ctx, sess, kind, relay, proc, extraEnv); err != nil {
|
||||
s.log.Error("acp.handshake_failed", "session_id", sess.ID, "err", err.Error())
|
||||
s.Kill(ctx, sess.ID, s.cfg.KillGrace)
|
||||
@@ -516,6 +530,14 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
|
||||
}
|
||||
}
|
||||
|
||||
// 取消 relay 的会话级 context:停止 reader/writer,并让仍阻塞在 Decide / 已派生
|
||||
// 的 handleAgentRequest goroutine 收到 ctx.Done 后退出。在 Relay.Close 之前调,
|
||||
// 二者共同完成 teardown(relayCancel 停 ctx 派生的 goroutine,Close 关 r.done +
|
||||
// 广播 session_terminated)。relayCancel 在早期 spawn 失败路径也可能为 nil。
|
||||
if proc.relayCancel != nil {
|
||||
proc.relayCancel()
|
||||
}
|
||||
|
||||
// 最后关 relay:广播 session_terminated 给 WS subscribers。
|
||||
proc.Relay.Close(string(status), &exitCode)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user