feat(server): allow weighted cross-workflow reads after a view check

Exact out-of-workflow documents can be summarized when WORKFLOW_SCOPE_MODE=weighted and the caller can still view the active instance; writes stay blocked.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 09:11:47 +00:00
co-authored by Cursor
parent fac5704303
commit 976f8d30b8
8 changed files with 242 additions and 16 deletions
+25
View File
@@ -70,6 +70,10 @@ type ReadableDocument struct {
Version int
Markdown string
IsTemplateOnly bool
// WorkflowKey 是文档归属工作流。CrossWorkflow 为真时正文来自非激活流,仅只读。
WorkflowKey string
ActiveWorkflow string
CrossWorkflow bool
}
// DocumentSummaryReader 由 HTTP 服务端按当前登录用户和项目绑定。
@@ -94,6 +98,27 @@ func WrongWorkflowGuidance(err error) string {
return ""
}
// ErrDocViewDenied 表示跨工作流文档存在,但当前用户无权查看。
type ErrDocViewDenied struct {
Message string
}
func (e *ErrDocViewDenied) Error() string {
if e == nil {
return "document view denied"
}
return e.Message
}
// DocViewDeniedGuidance 若错误链是跨流查看拒绝,返回话术。
func DocViewDeniedGuidance(err error) string {
var target *ErrDocViewDenied
if errors.As(err, &target) && target != nil && strings.TrimSpace(target.Message) != "" {
return target.Message
}
return ""
}
// UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费)
type UserProjectContext struct {
mu sync.Mutex
+27 -3
View File
@@ -31,6 +31,17 @@ func (e *CoordinatorEngine) handleDocumentSummary(
if guidance := WrongWorkflowGuidance(err); guidance != "" {
return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil
}
if guidance := DocViewDeniedGuidance(err); guidance != "" {
return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil
}
if owner := strings.TrimSpace(cand.FilledSlots[intent.SlotCrossWorkflowOwner]); owner != "" {
active := ""
if userCtx != nil {
active = userCtx.Project.ActiveWorkflowKey
}
note := fmt.Sprintf("您点名的《%s》属于【%s】,当前激活工作流为【%s】。当前项目台账里没有可读取的正文。", defaultStr(target, "该文档"), intent.WorkflowDisplayName(owner), intent.WorkflowDisplayName(active))
return documentSummaryReply(userCtx, note, ReplySourceTemplate), nil
}
return documentSummaryReply(userCtx, "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", ReplySourceTemplate), nil
}
if doc.IsTemplateOnly {
@@ -40,10 +51,23 @@ func (e *CoordinatorEngine) handleDocumentSummary(
return documentSummaryReply(userCtx, fmt.Sprintf("📄 《%s》正文为空,暂时无法总结。", defaultStr(doc.Title, doc.DocID)), ReplySourceTemplate), nil
}
if reply := e.generateDocumentSummaryReply(ctx, doc, userCtx); reply != "" {
return documentSummaryReply(userCtx, reply, ReplySourcePiAgent), nil
reply := ""
source := ReplySourceTemplate
if generated := e.generateDocumentSummaryReply(ctx, doc, userCtx); generated != "" {
reply = generated
source = ReplySourcePiAgent
} else {
reply = fallbackDocumentSummary(doc)
}
return documentSummaryReply(userCtx, fallbackDocumentSummary(doc), ReplySourceTemplate), nil
if doc.CrossWorkflow {
reply = crossWorkflowReadPreface(doc) + "\n\n" + reply
}
return documentSummaryReply(userCtx, reply, source), nil
}
func crossWorkflowReadPreface(doc *ReadableDocument) string {
title := defaultStr(doc.Title, doc.DocID)
return fmt.Sprintf("以下是【%s】的《%s》,当前激活工作流为【%s】。", intent.WorkflowDisplayName(doc.WorkflowKey), title, intent.WorkflowDisplayName(doc.ActiveWorkflow))
}
func documentSummaryReply(userCtx *UserProjectContext, reply, source string) *CoordinationResult {
+3 -1
View File
@@ -22,6 +22,7 @@ import (
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowscope"
)
// CoordinationStatus 协调裁决状态
@@ -959,7 +960,8 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
// 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。
if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" {
if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" && owner != userCtx.Project.ActiveWorkflowKey {
owner := intent.TaskKeyWorkflowOwner(taskKey)
if workflowscope.BlocksWrite(userCtx.Project.ActiveWorkflowKey, owner, taskKey) {
guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey)
return &CoordinationResult{
Status: StatusClarificationRequired,
+1
View File
@@ -161,6 +161,7 @@ const (
SlotWorkflowScopeMismatch = "workflow_scope_mismatch" // "true"
SlotMismatchedWorkflow = "mismatched_workflow" // 文档归属工作流 key(多个用逗号连接)
SlotMismatchedDoc = "mismatched_doc" // 被显式点名的他流文档标题
SlotCrossWorkflowOwner = "cross_workflow_owner" // 只读跨流放行时的归属工作流 key
// 长任务/运行槽位
SlotWorkDir = "work_dir"
+97 -12
View File
@@ -12,6 +12,7 @@ import (
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/workflowscope"
)
// bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。
@@ -37,21 +38,37 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth
if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil {
return nil, err
}
docs, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID)
listed, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID)
if err != nil {
return nil, err
}
docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs)
docs := s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, listed)
// 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。
// 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。
workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID)
if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" {
workflowKey = sess.UserCtx.Project.ActiveWorkflowKey
}
if workflowscope.CurrentMode() == workflowscope.ModeWeighted && len(scopeTaskKeys) > 0 {
// 权重模式把被阶段过滤掉的他流台账加回候选,胜出后再做查看权限校验。
docs = append(append([]docgov.WorkflowDocumentSummary{}, docs...), filterDocsOutsideTaskKeys(listed, scopeTaskKeys)...)
}
doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys)
if err != nil {
return nil, err
}
owner := intent.TaskKeyWorkflowOwner(doc.TaskKey)
cross := workflowKey != "" && owner != "" && owner != workflowKey &&
(len(scopeTaskKeys) == 0 || !projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey))
if cross && !s.crossWorkflowViewAllowed(ctx, projectID, userID, doc.TaskKey) {
title := doc.Title
if title == "" {
title = doc.TaskKey
}
return nil, &coordinator.ErrDocViewDenied{
Message: fmt.Sprintf("您无权查看【%s】的《%s》。", intent.WorkflowDisplayName(owner), title),
}
}
content, err := s.loadControlledDocumentContent(ctx, doc.DocID)
if err != nil {
return nil, err
@@ -64,6 +81,9 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth
Version: doc.Version,
Markdown: content.Markdown,
IsTemplateOnly: doc.IsTemplateOnly || content.IsTemplateOnly,
WorkflowKey: owner,
ActiveWorkflow: workflowKey,
CrossWorkflow: cross,
}, nil
}
}
@@ -96,16 +116,16 @@ func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int
return false, ""
}
owner := intent.TaskKeyWorkflowOwner(taskKey)
if owner == "" || owner == workflowKey {
return false, ""
if workflowscope.BlocksWrite(workflowKey, owner, taskKey) {
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
owner := intent.TaskKeyWorkflowOwner(taskKey)
if owner == "" || owner == workflowKey {
return false, ""
}
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
owner := intent.TaskKeyWorkflowOwner(taskKey)
if workflowscope.BlocksWrite(workflowKey, owner, taskKey) {
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
return false, ""
}
// workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。
@@ -121,9 +141,9 @@ func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string]
// errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。
type errDocWrongWorkflow struct {
Title string
TaskKey string
OwnerWorkflow string
Title string
TaskKey string
OwnerWorkflow string
ActiveWorkflow string
}
@@ -154,6 +174,71 @@ func selectReadableWorkflowDocument(
target string,
activeWorkflow string,
scopeTaskKeys map[string]bool,
) (*docgov.WorkflowDocumentSummary, error) {
if workflowscope.CurrentMode() == workflowscope.ModeWeighted && activeWorkflow != "" && len(scopeTaskKeys) > 0 && strings.TrimSpace(target) != "" {
return selectReadableWeighted(docs, target, activeWorkflow, scopeTaskKeys)
}
return selectReadableStrict(docs, target, activeWorkflow, scopeTaskKeys)
}
func selectReadableWeighted(
docs []docgov.WorkflowDocumentSummary,
target string,
activeWorkflow string,
scopeTaskKeys map[string]bool,
) (*docgov.WorkflowDocumentSummary, error) {
candidates := make([]workflowscope.Candidate, 0, len(docs))
byID := make(map[string]docgov.WorkflowDocumentSummary, len(docs))
for _, doc := range docs {
quality := workflowscope.BestQuality(target, doc.DocID, doc.TaskKey, doc.Title)
if quality <= 0 {
continue
}
owner := ""
if projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey) {
owner = activeWorkflow
} else if inferred := intent.TaskKeyWorkflowOwner(doc.TaskKey); inferred != "" {
owner = inferred
}
id := doc.DocID
if id == "" {
id = doc.TaskKey
}
candidates = append(candidates, workflowscope.Candidate{
ID: id,
Label: doc.Title,
Owner: owner,
Quality: quality,
})
byID[id] = doc
}
decision := workflowscope.Decide(candidates, activeWorkflow, workflowscope.ActionRead)
switch decision.Kind {
case workflowscope.KindInScope, workflowscope.KindCrossAllow:
doc := byID[decision.Winner.ID]
return &doc, nil
case workflowscope.KindCrossBlock:
doc := byID[decision.Winner.ID]
owner := decision.Winner.Owner
if owner == "" {
owner = intent.TaskKeyWorkflowOwner(doc.TaskKey)
}
return nil, (&errDocWrongWorkflow{
Title: doc.Title,
TaskKey: doc.TaskKey,
OwnerWorkflow: owner,
ActiveWorkflow: activeWorkflow,
}).AsCoordinatorErr()
default:
return nil, fmt.Errorf("document not found in readable scope")
}
}
func selectReadableStrict(
docs []docgov.WorkflowDocumentSummary,
target string,
activeWorkflow string,
scopeTaskKeys map[string]bool,
) (*docgov.WorkflowDocumentSummary, error) {
if len(docs) == 0 {
return nil, fmt.Errorf("no readable workflow documents")
@@ -7,6 +7,8 @@ import (
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/workflowscope"
)
func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testing.T) {
@@ -73,3 +75,47 @@ func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) {
t.Fatalf("want rd_proposal, got %s", doc.TaskKey)
}
}
func TestSelectReadableWeightedAllowsExactCrossRead(t *testing.T) {
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
t.Cleanup(workflowscope.ResetModeForTest)
docs := []docgov.WorkflowDocumentSummary{
{DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"},
{DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"},
}
scope := map[string]bool{"bd_research": true}
doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "main", scope)
if err != nil {
t.Fatalf("weighted cross read: %v", err)
}
if doc.TaskKey != "rd_proposal" {
t.Fatalf("want rd_proposal, got %s", doc.TaskKey)
}
inScope, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope)
if err != nil || inScope.TaskKey != "bd_research" {
t.Fatalf("in-scope still wins: %v %v", inScope, err)
}
}
func TestCrossWorkflowTaskVisible(t *testing.T) {
open := &projectinstance.StageAccess{
Restricted: true,
ViewableTaskKeys: map[string]bool{"bd_research": true},
}
scope := map[string]bool{"bd_research": true, "bd_hidden": true}
if !crossWorkflowTaskVisible(open, scope, "rd_proposal") {
t.Fatal("foreign key should be readable when the caller can view the active instance")
}
if crossWorkflowTaskVisible(open, scope, "bd_hidden") {
t.Fatal("in-scope but not viewable must be denied")
}
closed := &projectinstance.StageAccess{Restricted: true, ViewableTaskKeys: map[string]bool{}}
if crossWorkflowTaskVisible(closed, scope, "rd_proposal") {
t.Fatal("closed instance must deny cross read")
}
if !crossWorkflowTaskVisible(nil, scope, "rd_proposal") {
t.Fatal("unrestricted access must allow")
}
}
+28
View File
@@ -163,6 +163,34 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess
}
}
// crossWorkflowViewAllowed 判断他流文档是否允许当前用户只读。
// 解析失败按 fail-closed 开关拒绝;可见集合为空(实例已关闭)拒绝;
// 任务键属于激活实例但不可见则拒绝;键不在激活实例快照内、且调用方仍能查看本实例文档时允许。
func (s *Server) crossWorkflowViewAllowed(ctx context.Context, projectID, userID int64, taskKey string) bool {
access, err := s.resolveStageAccess(ctx, projectID, userID)
if err != nil {
return !s.failClosedAuthz()
}
_, scope := s.activeWorkflowScopeForDocs(ctx, projectID)
return crossWorkflowTaskVisible(access, scope, taskKey)
}
func crossWorkflowTaskVisible(access *projectinstance.StageAccess, scope map[string]bool, taskKey string) bool {
if access == nil || !access.Restricted {
return true
}
if access.TaskKeyVisible(taskKey) {
return true
}
if len(access.ViewableTaskKeys) == 0 {
return false
}
if len(scope) > 0 && projectinstance.TaskKeyInScope(scope, taskKey) {
return false
}
return true
}
func (s *Server) filterWorkflowDocsByStageAccess(
ctx context.Context,
projectID, userID int64,
+15
View File
@@ -156,6 +156,21 @@ func bestOf(candidates []Candidate, active string, scoreOf func(Candidate) float
return Decision{Kind: kind, Winner: best, Score: bestScore}
}
// BlocksWrite 报告一个已确定归属的任务键在写路径上是否应拦截。
// 归属未知或未激活时不拦截。
func BlocksWrite(active, owner, id string) bool {
if strings.TrimSpace(active) == "" || strings.TrimSpace(owner) == "" || owner == active {
return false
}
decision := Decide([]Candidate{{
ID: id,
Label: id,
Owner: owner,
Quality: QualityExact,
}}, active, ActionWrite)
return decision.Kind == KindCrossBlock
}
// BestQuality 取 query 对多个字段的最高匹配质量。
func BestQuality(query string, fields ...string) float64 {
best := 0.0