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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user