Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
e64fcc7fa1 fix(agents): gate on_comment trigger with private-agent visibility (MUL-2702)
Closes #3300.

After #2359 added canAccessPrivateAgent to chat, @mention, ListAgents,
GetAgent, history, edit, delete and issue assignment, one trigger path
was missed: shouldEnqueueOnComment. Once an owner/admin assigned a
private agent to an issue, the agent's UUID was "welded" onto that
issue and any workspace member who could view the issue could dispatch
a new task to it by posting a plain (non-@mention) comment — bypassing
the visibility gate the #2359 work was supposed to enforce.

Mirror the @mention path: plumb (authorType, authorID) from
CreateComment into shouldEnqueueOnComment, load the assigned agent, and
gate it with canAccessPrivateAgent before enqueueing. Add a Go
regression test on the existing privateAgentTestFixture covering the
plain-member, agent-owner, workspace-owner and agent-to-agent cases.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 18:48:32 +08:00
3 changed files with 107 additions and 3 deletions

View File

@@ -501,3 +501,94 @@ func TestMentionAgent_RejectsCrossWorkspaceAgentUUID(t *testing.T) {
beforeCount, afterCount)
}
}
// TestShouldEnqueueOnComment_PrivateAgentGate is the regression test for
// GH #3300: after an owner/admin assigns a private agent to an issue, the
// agent's UUID is "welded" onto that issue and any member with comment
// access could previously dispatch a new task to the private agent simply by
// posting a plain (non-@mention) comment, bypassing the visibility gate that
// #2359 added to chat / @mention / assignment.
//
// The gate must:
// - reject plain workspace members (not owner, not admin, not agent owner)
// - allow the agent owner
// - allow workspace owners/admins
// - allow agent-to-agent traffic regardless of agent visibility
func TestShouldEnqueueOnComment_PrivateAgentGate(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, ownerID, memberID := privateAgentTestFixture(t)
// Assign the private agent to a fresh issue. Owner/admin would normally
// be the one performing this step; we insert directly so the test
// focuses on the on_comment trigger path.
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id,
assignee_type, assignee_id, number)
VALUES ($1, 'on_comment private-agent gate test', 'todo', 'medium', 'member', $2,
'agent', $3,
COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1)
RETURNING id
`, testWorkspaceID, testUserID, agentID).Scan(&issueID); err != nil {
t.Fatalf("create issue assigned to private agent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
if err != nil {
t.Fatalf("load issue: %v", err)
}
cases := []struct {
name string
actorType string
actorID string
want bool
reason string
}{
{
name: "plain member — denied",
actorType: "member",
actorID: memberID,
want: false,
reason: "GH #3300: plain members must not be able to dispatch a task to a private agent via on_comment",
},
{
name: "agent owner — allowed",
actorType: "member",
actorID: ownerID,
want: true,
reason: "agent owner is always in the allowed_principals set",
},
{
name: "workspace owner — allowed",
actorType: "member",
actorID: testUserID,
want: true,
reason: "workspace owners/admins are in the allowed_principals set",
},
{
name: "agent-to-agent — allowed",
actorType: "agent",
actorID: agentID,
want: true,
reason: "A2A traffic bypasses the visibility gate by design",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := testHandler.shouldEnqueueOnComment(ctx, issue, tc.actorType, tc.actorID)
if got != tc.want {
t.Fatalf("%s\n actor=%s/%s got=%v want=%v",
tc.reason, tc.actorType, tc.actorID, got, tc.want)
}
})
}
}

View File

@@ -731,7 +731,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// the user is talking to someone else, not requesting work from the assignee.
// Also skip when replying in a member-started thread without mentioning the
// assignee — the user is continuing a member-to-member conversation.
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue, authorType, authorID) &&
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
// Always use the current comment as the trigger so the agent reads

View File

@@ -2540,8 +2540,21 @@ func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bo
// trigger the assigned agent. Fires for any status — comments are
// conversational and can happen at any stage, including after completion
// (e.g. follow-up questions on a done issue).
func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bool {
if !h.isAgentAssigneeReady(ctx, issue) {
//
// Mirrors the private-agent gate that enqueueMentionedAgentTasks applies on the
// @mention path: once an owner/admin assigns a private agent to an issue, the
// agent's UUID is "welded" onto the issue and remains visible to every member
// who can view it. Without this check any of those members could dispatch a new
// task to the private agent simply by commenting (#3300).
func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue, actorType, actorID string) bool {
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "agent" || !issue.AssigneeID.Valid {
return false
}
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
return false
}
if !h.canAccessPrivateAgent(ctx, agent, actorType, actorID, uuidToString(issue.WorkspaceID)) {
return false
}
// Coalescing queue: allow enqueue when a task is running (so the agent