feat: 加固 Agent CLI 执行边界并更新模型目录

收紧 SSH 命令护栏、权限校验与远程执行会话,并同步主流大模型供应商列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-09-14 09:43:14 +08:00
1 parent 67660ae1d6
commit 2fd1624bec
18 files changed
+480 -49

No files matched your search

+14 -9
View File
@@ -13,6 +13,7 @@ import (
"time"
"ezops/pkg/agentcli/evolver"
"ezops/pkg/agentcli/guardrail"
"ezops/pkg/agentcli/llm"
"ezops/pkg/agentcli/prompt"
"ezops/pkg/agentcli/safety"
@@ -293,9 +294,10 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
Content: "错误: 参数解析失败: " + err.Error()})
continue
}
safeArgs := guardrail.SanitizeMap(args)
if msg := a.suppressBroadKubernetesGet(tc.Function.Name, args, history); msg != "" {
a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: tools.RiskSafe.Display(), Summary: "工具执行中"})
a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: tools.RiskSafe.Display(), Summary: "工具执行中"})
a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name,
Output: msg, Success: true, Duration: "0s", OutputSize: len(msg)})
obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: msg})
@@ -303,12 +305,12 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
}
risk := a.toolRisk(tool, args)
a.emit(Event{Type: EventStatus, Phase: PhaseSafety, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "安全检查"})
a.emit(Event{Type: EventStatus, Phase: PhaseSafety, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: risk.Display(), Summary: "安全检查"})
// 安全检查(高危触发 confirmFn,由 controller 异步确认)
approved, err := a.safetyCtl.Check(tool, args)
if err != nil {
a.emit(Event{Type: EventStatus, Phase: PhaseError, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Message: err.Error()})
a.emit(Event{Type: EventStatus, Phase: PhaseError, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: risk.Display(), Message: guardrail.SanitizeText(err.Error())})
obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name,
Content: "安全检查失败: " + err.Error()})
continue
@@ -316,19 +318,19 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
if !approved {
obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name,
Content: "操作已被用户拒绝执行"})
a.emit(Event{Type: EventStatus, Phase: PhaseBlocked, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "用户拒绝执行"})
a.emit(Event{Type: EventStatus, Phase: PhaseBlocked, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: risk.Display(), Summary: "用户拒绝执行"})
a.emit(Event{Type: EventToolResult, Round: round, ToolName: tc.Function.Name,
Phase: PhaseObserving, Output: "(用户拒绝执行)", Success: false, OutputSize: len("(用户拒绝执行)")})
continue
}
// 执行
a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "工具执行中"})
a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: risk.Display(), Summary: "工具执行中"})
if cached, ok := a.cachedToolResult(tc.Function.Name, args); ok {
a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name,
Output: cached, Success: true, Duration: "0s", OutputSize: len(cached)})
records = append(records, evolver.ToolCallRecord{
ToolName: tc.Function.Name, Args: args, Output: cached,
ToolName: tc.Function.Name, Args: safeArgs, Output: cached,
Duration: 0, Success: true, RiskLevel: int(risk),
})
obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: cached})
@@ -349,6 +351,9 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
errMsg = execErr.Error()
success = false
}
// 工具结果必须先脱敏,再进入自动分析、事件、缓存、Evolver 和模型上下文。 // Logan.Li
output = guardrail.SanitizeText(output)
errMsg = guardrail.SanitizeText(errMsg)
// 超长命令输出自动分析,避免 token 爆炸 // Logan.Li
if (tc.Function.Name == "ssh_execute" || tc.Function.Name == "pod_execute") && success && len(output) > a.autoAnalyzeThreshold() {
@@ -365,10 +370,10 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name,
Output: output, Success: success, Duration: dur.Truncate(time.Millisecond).String(), OutputSize: len(output)})
a.storeToolResult(tc.Function.Name, args, output, success)
a.storeToolResult(tc.Function.Name, safeArgs, output, success)
records = append(records, evolver.ToolCallRecord{
ToolName: tc.Function.Name, Args: args, Output: output,
ToolName: tc.Function.Name, Args: safeArgs, Output: output,
Duration: dur, Success: success, RiskLevel: int(risk),
})
@@ -376,7 +381,7 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
var content string
switch {
case execErr != nil:
content = "执行错误: " + execErr.Error()
content = "执行错误: " + errMsg
failInc++
case !success:
content = fmt.Sprintf("执行失败: %s\n输出: %s", errMsg, output)
+13 -1
View File
@@ -15,7 +15,7 @@ var sensitivePatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)((?:mysql|postgres|postgresql|redis|mongodb)://[^:\s/@]+:)[^@\s]+(@)`),
regexp.MustCompile(`(?is)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`),
regexp.MustCompile(`(?i)((?:Cookie|Set-Cookie):\s*)[^\r\n]+`),
regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16})`),
regexp.MustCompile(`(?i)AKIA[0-9A-Z]{16}`),
}
var sensitiveKeys = map[string]bool{
@@ -65,6 +65,18 @@ func SanitizeJSON(raw string) string {
return string(b)
}
// SanitizeMap 返回脱敏后的参数副本,避免事件、审计和记忆泄露工具参数中的凭证。 // Logan.Li
func SanitizeMap(input map[string]interface{}) map[string]interface{} {
if input == nil {
return nil
}
sanitized, ok := sanitizeValue(input).(map[string]interface{})
if !ok {
return map[string]interface{}{}
}
return sanitized
}
func sanitizeValue(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
@@ -6,9 +6,9 @@ import (
)
func TestSanitizeTextMasksCommonSecrets(t *testing.T) {
input := "Authorization: Bearer abcdefghijklmnopqrstuvwxyz password=secret123 mysql://root:pass@db/prod"
input := "Authorization: Bearer abcdefghijklmnopqrstuvwxyz password=secret123 mysql://root:pass@db/prod AKIA1234567890ABCDEF"
got := SanitizeText(input)
for _, leak := range []string{"abcdefghijklmnopqrstuvwxyz", "secret123", "root:pass@db"} {
for _, leak := range []string{"abcdefghijklmnopqrstuvwxyz", "secret123", "root:pass@db", "AKIA1234567890ABCDEF"} {
if strings.Contains(got, leak) {
t.Fatalf("敏感信息未脱敏: %s in %s", leak, got)
}
@@ -29,3 +29,22 @@ func TestSanitizeJSONMasksSensitiveKeys(t *testing.T) {
t.Fatalf("非敏感字段不应被改写: %s", got)
}
}
func TestSanitizeMapReturnsMaskedCopy(t *testing.T) {
input := map[string]interface{}{
"command": "curl -H 'Authorization: Bearer abcdefghijklmnop' https://example.test",
"nested": map[string]interface{}{"password": "plain-secret"},
}
got := SanitizeMap(input)
if got["command"] == input["command"] {
t.Fatal("命令中的令牌未脱敏")
}
nested := got["nested"].(map[string]interface{})
if nested["password"] != "[REDACTED]" {
t.Fatalf("密码字段未脱敏: %#v", nested["password"])
}
if input["nested"].(map[string]interface{})["password"] != "plain-secret" {
t.Fatal("脱敏不得修改原始参数")
}
}
+17 -4
View File
@@ -19,6 +19,8 @@ type SSHExecuteTool struct {
timeout time.Duration // 默认命令超时
}
const maxSSHCommandTimeout = 300 * time.Second
// NewSSHExecuteTool 创建远程执行工具(内部建立 SSH 长连接) // Logan.Li
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) (*SSHExecuteTool, error) {
if timeout <= 0 {
@@ -82,12 +84,12 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
return &Result{Success: false, Error: "参数 command 不能为空"}, nil
}
timeout := t.timeout
timeout := boundedSSHTimeout(t.timeout)
if sec := IntParam(args, "timeout"); sec > 0 {
timeout = time.Duration(sec) * time.Second
timeout = boundedSSHTimeout(time.Duration(sec) * time.Second)
}
res, err := t.client.Exec(command, timeout)
res, err := t.client.ExecContext(ctx, command, timeout)
if err != nil {
return &Result{Success: false, Error: fmt.Sprintf("SSH 连接失败: %v", err)}, nil
}
@@ -103,4 +105,15 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
Output: output,
Error: res.Error,
}, nil
}
}
// boundedSSHTimeout 将模型提供的超时限制在平台允许范围内。 // Logan.Li
func boundedSSHTimeout(timeout time.Duration) time.Duration {
if timeout <= 0 {
return time.Second
}
if timeout > maxSSHCommandTimeout {
return maxSSHCommandTimeout
}
return timeout
}
@@ -0,0 +1,25 @@
package tools
import (
"testing"
"time"
)
func TestBoundedSSHTimeout(t *testing.T) {
cases := []struct {
name string
in time.Duration
want time.Duration
}{
{name: "non-positive", in: 0, want: time.Second},
{name: "normal", in: 60 * time.Second, want: 60 * time.Second},
{name: "too-large", in: 24 * time.Hour, want: 300 * time.Second},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := boundedSSHTimeout(tc.in); got != tc.want {
t.Fatalf("boundedSSHTimeout(%v)=%v, want %v", tc.in, got, tc.want)
}
})
}
}
+31 -7
View File
@@ -29,13 +29,21 @@ func NewPersistentClient(cfg SSHConfig) (*PersistentClient, error) {
// Exec 在长连接上执行命令;连接断开时自动重连一次 // Logan.Li
func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecResult, error) {
return p.ExecContext(context.Background(), command, timeout)
}
// ExecContext 在长连接上执行命令,并在调用方取消时关闭当前远端 session。 // Logan.Li
func (p *PersistentClient) ExecContext(ctx context.Context, command string, timeout time.Duration) (*ExecResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
result, err := p.execLocked(command, timeout)
result, err := p.execLockedContext(ctx, command, timeout)
if err == nil {
return result, nil
}
if ctx.Err() != nil {
return result, ctx.Err()
}
// 连接级失败:重连后重试一次 // Logan.Li
if p.client != nil {
_ = p.client.Close()
@@ -46,11 +54,16 @@ func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecRes
return result, err
}
p.client = client
return p.execLocked(command, timeout)
return p.execLockedContext(ctx, command, timeout)
}
// execLocked 假定已持锁,在当前 client 上执行 // Logan.Li
func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*ExecResult, error) {
return p.execLockedContext(context.Background(), command, timeout)
}
// execLockedContext 假定已持锁,并将调用方上下文传递给当前 SSH session。 // Logan.Li
func (p *PersistentClient) execLockedContext(ctx context.Context, command string, timeout time.Duration) (*ExecResult, error) {
if p.client == nil {
client, err := connect(p.cfg)
if err != nil {
@@ -58,7 +71,7 @@ func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*E
}
p.client = client
}
return execWithClient(p.client, p.cfg, command, timeout)
return execWithClientContext(ctx, p.client, p.cfg, command, timeout)
}
// Close 关闭长连接 // Logan.Li
@@ -75,6 +88,11 @@ func (p *PersistentClient) Close() error {
// execWithClient 在已有 SSH client 上执行命令(供 Exec / PersistentClient 共用) // Logan.Li
func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
return execWithClientContext(context.Background(), client, cfg, command, timeout)
}
// execWithClientContext 在已有 SSH client 上执行命令,并响应调用方取消。 // Logan.Li
func execWithClientContext(parent context.Context, client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
start := time.Now()
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
@@ -90,7 +108,7 @@ func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout t
timeout = 60 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
type execOut struct {
@@ -117,10 +135,16 @@ func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout t
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGKILL)
_ = session.Close()
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
if parent.Err() != nil {
result.Error = "执行已取消"
} else {
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
}
result.ExitCode = -1
return result, fmt.Errorf("exec timeout after %v", timeout)
return result, ctx.Err()
case out := <-ch:
result.Duration = time.Since(start).Milliseconds()
result.Output = string(out.output)
@@ -141,4 +165,4 @@ func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout t
}
}
return result, nil
}
}