From 6b0ff49f34d67b25bcd21ce2ccfbfb8b918e38bf Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 3 Oct 2026 17:01:34 +0000 Subject: [PATCH 1/2] Gate tools by session mode and show confirmation details Why: Plan mode has to block writes, confirmations dropped the argument preview, and direct chat did not share its stop gate with squad members. Evidence: GOWORK=off go test ./internal/store/ ./internal/tools/ ./internal/runmgr/ ./internal/runtime/ ./internal/server/ passed. Default mode still runs write_artifact; plan mode rejects it. Impact: Empty permission mode keeps the profile allow, deny, and confirm lists. High-risk shell commands now ask. There is no bypass-all mode. Refs: feature/permission-hooks Co-authored-by: Cursor --- deps/agent-native | 2 +- deps/agent-squad | 2 +- deps/agent-system-core | 2 +- internal/app/app.go | 16 ++++ internal/runmgr/perm_test.go | 38 ++++++++++ internal/runmgr/runmgr.go | 118 +++++++++++++++++++++++------ internal/runtime/llm.go | 22 ++++++ internal/runtime/llm_direct.go | 14 +++- internal/server/server.go | 28 ++++--- internal/store/migrate.go | 11 +++ internal/store/projects.go | 30 ++++++-- internal/store/store_test.go | 4 +- internal/tools/ask.go | 109 ++++++++++++++++++++++++++ internal/tools/exec_interpreter.go | 4 +- internal/tools/fs.go | 6 +- internal/tools/registry.go | 11 ++- 16 files changed, 363 insertions(+), 54 deletions(-) create mode 100644 internal/runmgr/perm_test.go create mode 100644 internal/tools/ask.go diff --git a/deps/agent-native b/deps/agent-native index 6fd1d44..99d623a 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 6fd1d441a95423e27be6f6159207d5d0821bec4d +Subproject commit 99d623a8bb6f1654124c9c25cc4a03a0b89ed0ef diff --git a/deps/agent-squad b/deps/agent-squad index 4d53450..21a372e 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit 4d534508a94c59cf865549125a9cdf1303271a22 +Subproject commit 21a372eb843b8381a1d1d308fe0c75ea7d122722 diff --git a/deps/agent-system-core b/deps/agent-system-core index 7c66075..5cbdaad 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 7c66075ee9caa9f990d6d6ad7619f7af8d9040e8 +Subproject commit 5cbdaad2a2f72c869bdcb0e27a144bb8f6ca68e5 diff --git a/internal/app/app.go b/internal/app/app.go index e689370..47561d9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -25,6 +25,7 @@ import ( agentllm "agent-native/runtime/llm" agentmcp "agent-native/runtime/mcp" + "agent-native/runtime/policy" agenttools "agent-native/runtime/tools" nativeadp "agent-squad/adapters/native" "agent-system-backend/internal/bus" @@ -149,12 +150,27 @@ func Run(opt Options) error { if route.Model == "" || route.Base == "" { return nativeadp.Config{}, false } + mode := "default" + if src, ok := rc.(interface{ PermissionMode() string }); ok { + mode = src.PermissionMode() + } cfg := nativeadp.Config{ Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key, HTTPClient: &http.Client{}}, DefaultModel: route.Model, MaxTurns: 0, Timeout: 0, Subagent: squadadapter.SubagentFactory(rc), + RepeatGuard: true, + BeforeTool: func(call agentllm.ToolCall) error { + effect := policy.Decide(policy.Request{ + Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments, + Traits: policy.TraitsFor(call.Name), + }, nil) + if effect == policy.EffectDeny { + return fmt.Errorf("计划模式拒绝了工具 %s", call.Name) + } + return nil + }, } if keys := splitKeyPool(effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 { cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys) diff --git a/internal/runmgr/perm_test.go b/internal/runmgr/perm_test.go new file mode 100644 index 0000000..57b1349 --- /dev/null +++ b/internal/runmgr/perm_test.go @@ -0,0 +1,38 @@ +package runmgr + +import ( + "testing" + + core "agent-system-core" +) + +func TestPlanModeRejectsWriteAndKeepsRead(t *testing.T) { + c, st := newExecEnv(t) + if err := st.SetPermissionMode("alice", c.run.ConversationID, "plan"); err != nil { + t.Fatal(err) + } + c.permissionMode = "plan" + res := c.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "write_artifact", Arguments: []byte(`{"name":"a.md","content":"x"}`)}) + if res.Status != "error" || res.Output == "" { + t.Fatalf("write in plan mode: %+v", res) + } + ok := c.ExecuteTool(core.ToolCallRequest{CallID: "c2", Name: "time_now", Arguments: []byte(`{}`)}) + if ok.Status != "ok" { + t.Fatalf("read in plan mode: %+v", ok) + } + names := map[string]bool{} + for _, tool := range c.Tools() { + names[tool.Name] = true + } + if names["write_artifact"] || !names["time_now"] || !names["ask_user"] || !names["exit_plan_mode"] { + t.Fatalf("plan tool list: %v", names) + } +} + +func TestDefaultModeStillRunsWrite(t *testing.T) { + c, _ := newExecEnv(t) + res := c.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "write_artifact", Arguments: []byte(`{"name":"a.md","content":"x"}`)}) + if res.Status != "ok" { + t.Fatalf("default write: %+v", res) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index bb7717b..eb3f513 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -16,6 +16,7 @@ import ( "unicode/utf8" "agent-native/runtime/filestate" + agentpolicy "agent-native/runtime/policy" "agent-native/runtime/subagent" "agent-system-backend/internal/bus" @@ -68,6 +69,9 @@ type Manager struct { notices map[string][]bgNotice bgInFlight map[string]int delivering map[string]bool + + allowMu sync.Mutex + sessionAllow map[string]map[string]bool } const maxBackgroundChildren = 3 @@ -150,20 +154,21 @@ func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir } // ---- runCtx 实现(runtime.RunCtx)---- type runCtx struct { - mgr *Manager - run core.Run - prof core.AgentProfile - eff core.EffectiveSettings - ctx context.Context - h *runHandle - userMessage core.Message - attachments []core.ParsedDocument - inputPaths []string - subTask string - subHint string - subAgent string - launch *workflow.Launch - files *filestate.Tracker + mgr *Manager + run core.Run + prof core.AgentProfile + eff core.EffectiveSettings + ctx context.Context + h *runHandle + userMessage core.Message + attachments []core.ParsedDocument + inputPaths []string + subTask string + subHint string + subAgent string + launch *workflow.Launch + files *filestate.Tracker + permissionMode string } // Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。 @@ -236,16 +241,64 @@ func (c *runCtx) Subtask() string { return c.subTask } // Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤 // (与 /api/tools 列表同门禁;registry.Execute 另有执行侧 fail-closed)。 +func conversationMode(m *Manager, owner, id string) string { + if m == nil || m.st == nil || id == "" { + return "" + } + conv, err := m.st.GetConversation(owner, id) + if err != nil { + return "" + } + return conv.PermissionMode +} + +func (c *runCtx) PermissionMode() string { return c.permissionMode } + +func (c *runCtx) ExitPlanMode() error { + c.permissionMode = "" + if c.mgr == nil || c.mgr.st == nil { + return nil + } + return c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default") +} + +func (m *Manager) rememberSessionAllow(conv, tool string) { + if m == nil || conv == "" || tool == "" { + return + } + m.allowMu.Lock() + defer m.allowMu.Unlock() + if m.sessionAllow == nil { + m.sessionAllow = map[string]map[string]bool{} + } + if m.sessionAllow[conv] == nil { + m.sessionAllow[conv] = map[string]bool{} + } + m.sessionAllow[conv][tool] = true +} + +func (m *Manager) sessionAllows(conv, tool string) bool { + if m == nil { + return false + } + m.allowMu.Lock() + defer m.allowMu.Unlock() + return m.sessionAllow[conv][tool] +} + func (c *runCtx) Tools() []core.ToolDesc { list := c.mgr.registry.List() - if c.eff.GetBool("runtime.fsTools", false) { - return list - } out := make([]core.ToolDesc, 0, len(list)) + fs := c.eff.GetBool("runtime.fsTools", false) for _, t := range list { - if !t.Hidden { - out = append(out, t) + if t.Hidden && !fs { + continue } + readOnly := t.ReadOnly || agentpolicy.TraitsFor(t.Name).ReadOnly + if !agentpolicy.Visible(c.permissionMode, t.Name, readOnly) { + continue + } + out = append(out, t) } return out } @@ -582,25 +635,36 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { if !policy.ToolAllowed(call.Name) { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)} } - if policy.NeedsConfirm(call.Name) { + desc, _, _ := c.mgr.registry.Get(call.Name) + effect := agentpolicy.Decide(agentpolicy.Request{ + Mode: agentpolicy.Normalize(c.permissionMode), Name: call.Name, Args: string(call.Arguments), + Traits: agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk}, + SessionAllow: c.mgr.sessionAllows(c.run.ConversationID, call.Name), + }, nil) + if effect == agentpolicy.EffectDeny { + return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("计划模式拒绝了工具 %s", call.Name)} + } + if effect != agentpolicy.EffectAllow && (effect == agentpolicy.EffectAsk || policy.NeedsConfirm(call.Name)) { var argsPreview string if len(call.Arguments) > 0 { argsPreview = truncate(string(call.Arguments), 600) } card := core.HITLCard{ - Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm, + Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm, Detail: argsPreview, Choices: []core.HITLChoice{ {ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true}, + {ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"}, {ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"}, }, AllowFreeInput: false, } - _ = argsPreview resp, err := c.AskHITL(card) if err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()} } - if resp.ChoiceID != "yes" { + if resp.ChoiceID == "session" { + c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name) + } else if resp.ChoiceID != "yes" { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"} } } @@ -655,6 +719,10 @@ func (t *toolCtx) Context() context.Context { return t.rc.Context() func (t *toolCtx) OwnerID() string { return t.rc.run.Owner } func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot } func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) } +func (t *toolCtx) AskUser(card core.HITLCard) (core.HITLResponse, error) { + return t.rc.AskHITL(card) +} +func (t *toolCtx) ExitPlanMode() error { return t.rc.ExitPlanMode() } // EmitToolOutput 把解释器执行中的输出增量发到会话事件流。 func (t *toolCtx) EmitToolOutput(stream, text string) { @@ -875,7 +943,7 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa runner := selectConversationRunner(m, eff, launch) inputPaths, _ := m.stageInputs(conv, eff, inputs) - rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New()} + rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)} go m.execute(rc, runner) return run, nil } @@ -1203,7 +1271,7 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID) eff := m.settings.Effective(parent.Owner, parent.ProjectID) - rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New()} + rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)} m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing) runner := m.subRunner diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index d6a1a6f..50b4f65 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -11,6 +11,7 @@ import ( nruntime "agent-native/runtime" agentllm "agent-native/runtime/llm" + "agent-native/runtime/policy" agenttools "agent-native/runtime/tools" "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/prompts" @@ -283,6 +284,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { decls = append(decls, agentllm.ToolDecl{Name: td.Name, Description: td.Description, Parameters: td.Parameters}) } } + decls = filterPlanDecls(decls, permissionModeOf(rc)) decls = applyToolSelection(decls, eff.GetString("runtime.toolSelection", "")) maxTurns, maxTools := loopCaps(prof.Budget) @@ -290,6 +292,26 @@ func (l *LLMRunner) Execute(rc RunCtx) error { return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools) } +func permissionModeOf(rc RunCtx) string { + if m, ok := rc.(interface{ PermissionMode() string }); ok { + return m.PermissionMode() + } + return "" +} + +func filterPlanDecls(decls []agentllm.ToolDecl, mode string) []agentllm.ToolDecl { + if policy.Normalize(mode) != policy.ModePlan { + return decls + } + out := make([]agentllm.ToolDecl, 0, len(decls)) + for _, d := range decls { + if policy.Visible(mode, d.Name, policy.TraitsFor(d.Name).ReadOnly) { + out = append(out, d) + } + } + return out +} + func applyToolSelection(decls []agentllm.ToolDecl, spec string) []agentllm.ToolDecl { spec = strings.TrimSpace(spec) if spec == "" { diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index ee05ca5..6aa6c4e 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -8,6 +8,7 @@ import ( "time" nruntime "agent-native/runtime" + "agent-native/runtime/hooks" agentllm "agent-native/runtime/llm" "agent-native/runtime/reminders" agenttools "agent-native/runtime/tools" @@ -145,6 +146,17 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin } window, reserve, buffer := contextWindow(st.eff) + bus := hooks.New() + bus.On(func(c hooks.Call) hooks.Outcome { + if c.Event != hooks.Stop { + return hooks.Outcome{} + } + d := st.stop(agentllm.Message{Role: agentllm.RoleAssistant, Content: c.LastText}, nruntime.StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls}) + if d.Continue { + return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice} + } + return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice} + }) res, err := nruntime.Run(rc.Context(), nruntime.Config{ Model: model, History: history, @@ -159,7 +171,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin OutputReserve: reserve, ContextBuffer: buffer, OnEvent: st.onEvent, - Stop: st.stop, + Hooks: bus, Reminders: st.reminders, UnknownTool: func(call agentllm.ToolCall) agenttools.Result { return st.runTool(call.Name, call.Arguments) diff --git a/internal/server/server.go b/internal/server/server.go index 79d2ab0..1f4dc16 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -513,17 +513,27 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) { func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req struct { - Title string `json:"title"` - ProfileID string `json:"profileId"` - Pinned *bool `json:"pinned"` - Archived *bool `json:"archived"` - AllowCrossRead *bool `json:"allowCrossRead"` - CrossPrivate *bool `json:"crossPrivate"` + Title string `json:"title"` + ProfileID string `json:"profileId"` + Pinned *bool `json:"pinned"` + Archived *bool `json:"archived"` + AllowCrossRead *bool `json:"allowCrossRead"` + CrossPrivate *bool `json:"crossPrivate"` + PermissionMode *string `json:"permissionMode"` } json.NewDecoder(r.Body).Decode(&req) - if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil { - writeStoreErr(w, err) - return + onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil + if !onlyMode { + if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil { + writeStoreErr(w, err) + return + } + } + if req.PermissionMode != nil { + if err := s.st.SetPermissionMode(s.owner(r), id, *req.PermissionMode); err != nil { + writeStoreErr(w, err) + return + } } writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate)) } diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 850f9c3..2eb67ec 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -390,6 +390,17 @@ func init() { `ALTER TABLE messages DROP COLUMN request_id`, `ALTER TABLE runs DROP COLUMN instance_id`, }}}, + migration{version: 15, name: "conversation_permission_mode", + up: map[string][]string{"sqlite": { + `ALTER TABLE conversations ADD COLUMN permission_mode TEXT NOT NULL DEFAULT ''`, + }, "postgres": { + `ALTER TABLE conversations ADD COLUMN permission_mode TEXT NOT NULL DEFAULT ''`, + }}, + down: map[string][]string{"sqlite": { + `ALTER TABLE conversations DROP COLUMN permission_mode`, + }, "postgres": { + `ALTER TABLE conversations DROP COLUMN permission_mode`, + }}}, ) } diff --git a/internal/store/projects.go b/internal/store/projects.go index 07e88fa..9a56c51 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -3,6 +3,7 @@ package store import ( "database/sql" "errors" + "fmt" "strings" "time" @@ -137,7 +138,7 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c } func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) { - rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner) + rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner) if err != nil { return nil, err } @@ -156,7 +157,7 @@ func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, // ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。 func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) { - q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,created_at,updated_at FROM conversations WHERE owner=?` + q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE owner=?` if !includeArchived { q += ` AND archived=0` } @@ -181,7 +182,7 @@ func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversati func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error { var ca, ua string var pinned, archived, allowCrossRead, crossPrivate int - if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &ca, &ua); err != nil { + if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua); err != nil { return err } c.Pinned = pinned == 1 @@ -197,8 +198,8 @@ func (s *Store) GetConversation(owner, id string) (core.Conversation, error) { var c core.Conversation var ca, ua string var pinned, archived, allowCrossRead, crossPrivate int - err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner). - Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &ca, &ua) + err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner). + Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua) if errors.Is(err, sql.ErrNoRows) { return c, ErrNotFound } @@ -274,6 +275,25 @@ func (s *Store) SetConversationFlags(owner, id string, pinned, archived, allowCr return nil } +func (s *Store) SetPermissionMode(owner, id, mode string) error { + switch mode { + case "", "default", "plan", "auto_edit": + default: + return fmt.Errorf("store: unknown permission mode %q", mode) + } + if mode == "default" { + mode = "" + } + res, err := s.db.Exec(s.q(`UPDATE conversations SET permission_mode=?, updated_at=? WHERE id=? AND owner=?`), mode, nowISO(), id, owner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + func (s *Store) DeleteConversation(owner, id string) error { return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6b37a60..1729391 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -56,8 +56,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 14 { - t.Fatalf("expected versions [1..14], got %v", got) + if len(got) != 15 { + t.Fatalf("expected versions [1..15], got %v", got) } // 老库形态(v2 前):events 全局自增、settings 单列主键。 if err := st.MigrateDown(1); err != nil { diff --git a/internal/tools/ask.go b/internal/tools/ask.go new file mode 100644 index 0000000..81589c9 --- /dev/null +++ b/internal/tools/ask.go @@ -0,0 +1,109 @@ +package tools + +import ( + "encoding/json" + "fmt" + "strings" + + core "agent-system-core" +) + +type userAsker interface { + AskUser(card core.HITLCard) (core.HITLResponse, error) +} + +type planExiter interface { + ExitPlanMode() error +} + +func registerAskTools(r *Registry) error { + if err := r.Register(core.ToolDesc{ + Name: "ask_user", Description: "向用户提一个问题。可以给选项,用户也可以自己输入。", Source: core.SourceBuiltin, ReadOnly: true, + Parameters: json.RawMessage(`{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"string"}}},"required":["question"]}`), + }, execAskUser); err != nil { + return err + } + return r.Register(core.ToolDesc{ + Name: "exit_plan_mode", Description: "结束计划模式。用户批准后,本会话余下的执行改回默认权限。", Source: core.SourceBuiltin, ReadOnly: true, + Parameters: json.RawMessage(`{"type":"object","properties":{"summary":{"type":"string"}}}`), + }, execExitPlan) +} + +func execAskUser(rc ToolExecCtx, args json.RawMessage) (string, error) { + ask, ok := rc.(userAsker) + if !ok { + return "", fmt.Errorf("ask_user 未接入") + } + var a struct { + Question string `json:"question"` + Options []string `json:"options"` + } + if err := json.Unmarshal(args, &a); err != nil || strings.TrimSpace(a.Question) == "" { + return "", fmt.Errorf("ask_user: question required") + } + card := core.HITLCard{ + Question: a.Question, Kind: core.HITLKindChoice, AllowFreeInput: true, + FreeInputHint: "也可以直接输入", + } + for i, opt := range a.Options { + opt = strings.TrimSpace(opt) + if opt == "" { + continue + } + card.Choices = append(card.Choices, core.HITLChoice{ + ID: fmt.Sprintf("opt%d", i+1), Label: opt, Description: opt, Impact: "模型会按这个选择继续", + }) + } + if len(card.Choices) == 0 { + card.Kind = core.HITLKindInput + } + resp, err := ask.AskUser(card) + if err != nil { + return "", err + } + if text := strings.TrimSpace(resp.FreeText); text != "" { + return "用户回答:" + text, nil + } + for _, ch := range card.Choices { + if ch.ID == resp.ChoiceID { + return "用户选择:" + ch.Label, nil + } + } + if resp.ChoiceID != "" { + return "用户选择:" + resp.ChoiceID, nil + } + return "用户没有给出回答", nil +} + +func execExitPlan(rc ToolExecCtx, args json.RawMessage) (string, error) { + ask, ok := rc.(userAsker) + exit, okExit := rc.(planExiter) + if !ok || !okExit { + return "", fmt.Errorf("exit_plan_mode 未接入") + } + var a struct { + Summary string `json:"summary"` + } + _ = json.Unmarshal(args, &a) + q := "批准计划并继续执行?" + if s := strings.TrimSpace(a.Summary); s != "" { + q = "批准计划并继续执行?\n" + s + } + resp, err := ask.AskUser(core.HITLCard{ + Question: q, Kind: core.HITLKindConfirm, Detail: strings.TrimSpace(a.Summary), + Choices: []core.HITLChoice{ + {ID: "yes", Label: "批准", Description: "结束计划模式,按默认权限继续", Impact: "之后可以改文件", Recommended: true}, + {ID: "no", Label: "继续计划", Description: "留在计划模式", Impact: "写文件仍然被拒绝"}, + }, + }) + if err != nil { + return "", err + } + if resp.ChoiceID != "yes" { + return "用户还没有批准计划", nil + } + if err := exit.ExitPlanMode(); err != nil { + return "", err + } + return "已切回默认权限", nil +} diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index 84e6535..7dc2f9e 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -30,7 +30,7 @@ import ( // RegisterInterpreterTools 注册解释器执行原语(run_shell / run_python)。 func RegisterInterpreterTools(r *Registry) error { if err := r.Register(core.ToolDesc{ - Name: "run_shell", Source: core.SourceBuiltin, + Name: "run_shell", Source: core.SourceBuiltin, Risk: "medium", Description: "在会话工作区目录内执行 bash 脚本:检查环境、安装依赖(pip install --user …)、处理文件等。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。以当前用户身份运行。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"script":{"type":"string","description":"bash 脚本(可多行)"},` + @@ -40,7 +40,7 @@ func RegisterInterpreterTools(r *Registry) error { return err } return r.Register(core.ToolDesc{ - Name: "run_python", Source: core.SourceBuiltin, + Name: "run_python", Source: core.SourceBuiltin, Risk: "medium", Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` + diff --git a/internal/tools/fs.go b/internal/tools/fs.go index 8614f74..9b0ef9a 100644 --- a/internal/tools/fs.go +++ b/internal/tools/fs.go @@ -24,19 +24,19 @@ const ( // RegisterFSTools 注册隐藏 fs 工具(read/write/list)。root 由宿主注入(空=未配置,执行时报错)。 func RegisterFSTools(r *Registry) error { if err := r.Register(core.ToolDesc{ - Name: "fs_read_file", Description: "读取会话工作区内的文本文件(≤1MB)", Source: core.SourceBuiltin, Hidden: true, + Name: "fs_read_file", Description: "读取会话工作区内的文本文件(≤1MB)", Source: core.SourceBuiltin, Hidden: true, ReadOnly: true, Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径"}},"required":["path"]}`), }, execFSRead); err != nil { return err } if err := r.Register(core.ToolDesc{ - Name: "fs_write_file", Description: "把文本写入会话工作区文件(≤2MB),并同步登记为可下载工件", Source: core.SourceBuiltin, Hidden: true, + Name: "fs_write_file", Description: "把文本写入会话工作区文件(≤2MB),并同步登记为可下载工件", Source: core.SourceBuiltin, Hidden: true, Edits: true, Risk: "medium", Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`), }, execFSWrite); err != nil { return err } return r.Register(core.ToolDesc{ - Name: "fs_list_dir", Description: "列出会话工作区目录(一层,≤200 条)", Source: core.SourceBuiltin, Hidden: true, + Name: "fs_list_dir", Description: "列出会话工作区目录(一层,≤200 条)", Source: core.SourceBuiltin, Hidden: true, ReadOnly: true, Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径,空=根"}}}`), }, execFSList) } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 7720fb6..c5900b2 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -111,7 +111,7 @@ func (r *Registry) Execute(rc ToolExecCtx, call core.ToolCallRequest) core.ToolC // RegisterBuiltin 注册骨架期内置工具:时间、会话历史检索、知识检索、读文件、写工件。 func (r *Registry) RegisterBuiltin() error { if err := r.Register(core.ToolDesc{ - Name: "time_now", Description: "获取当前时间(RFC3339)与时区", Source: core.SourceBuiltin, + Name: "time_now", Description: "获取当前时间(RFC3339)与时区", Source: core.SourceBuiltin, ReadOnly: true, Parameters: json.RawMessage(`{"type":"object","properties":{}}`), }, func(rc ToolExecCtx, args json.RawMessage) (string, error) { return time.Now().Format(time.RFC3339), nil @@ -119,7 +119,7 @@ func (r *Registry) RegisterBuiltin() error { return err } if err := r.Register(core.ToolDesc{ - Name: "knowledge_search", Description: "在项目知识与插件知识库中做全文检索,返回命中片段", Source: core.SourceBuiltin, + Name: "knowledge_search", Description: "在项目知识与插件知识库中做全文检索,返回命中片段", Source: core.SourceBuiltin, ReadOnly: true, Parameters: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","description":"检索词"}},"required":["query"]}`), }, func(rc ToolExecCtx, args json.RawMessage) (string, error) { var a struct { @@ -150,7 +150,7 @@ func (r *Registry) RegisterBuiltin() error { return err } if err := r.Register(core.ToolDesc{ - Name: "write_artifact", Description: "把生成内容写入工件(文档/数据),返回工件 ID 与名称", Source: core.SourceBuiltin, + Name: "write_artifact", Description: "把生成内容写入工件(文档/数据),返回工件 ID 与名称", Source: core.SourceBuiltin, Edits: true, Risk: "medium", Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["document","data"]},"content":{"type":"string"}},"required":["name","content"]}`), }, func(rc ToolExecCtx, args json.RawMessage) (string, error) { var a struct { @@ -186,5 +186,8 @@ func (r *Registry) RegisterBuiltin() error { if err := registerQualityReview(r); err != nil { return err } - return registerPeerTools(r) + if err := registerPeerTools(r); err != nil { + return err + } + return registerAskTools(r) } -- 2.54.0 From ce95cd1355e31317ca8a585db43f66bd8767d910 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 3 Oct 2026 17:02:47 +0000 Subject: [PATCH 2/2] Pin rebased native, squad, and core permission commits Why: Those three branches were replayed onto origin/main after the first pin. Evidence: native 0a52d9f, squad beea97e, and core cf7df54 each contain origin/main. Impact: The backend build uses the same commits that will be merged. Refs: feature/permission-hooks Co-authored-by: Cursor --- deps/agent-native | 2 +- deps/agent-squad | 2 +- deps/agent-system-core | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deps/agent-native b/deps/agent-native index 99d623a..0a52d9f 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 99d623a8bb6f1654124c9c25cc4a03a0b89ed0ef +Subproject commit 0a52d9f9763372ef1b4d061934d60f15fd8bcbea diff --git a/deps/agent-squad b/deps/agent-squad index 21a372e..beea97e 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit 21a372eb843b8381a1d1d308fe0c75ea7d122722 +Subproject commit beea97e8cf8f509289545827bc3034a940433ca4 diff --git a/deps/agent-system-core b/deps/agent-system-core index 5cbdaad..cf7df54 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 5cbdaad2a2f72c869bdcb0e27a144bb8f6ca68e5 +Subproject commit cf7df54874c20f8ae9cb4e48b2c75f9ef6968f14 -- 2.54.0