Compare commits

...

5 Commits

Author SHA1 Message Date
Jiang Bohan
90bc37a9ef test(handler): finish X-Task-ID seeding + fix cross-workspace mention test schema
The previous CI run still failed in two places:

1. server/cmd/server integration tests — postCommentAsAgent → authRequestWithAgent
   only set X-Agent-ID, so resolveActor downgraded the request to "member"
   and the on_comment chain produced the wrong task counts. Fix:
   authRequestWithAgent now also sets X-Task-ID, fetched or seeded by a new
   ensureAgentTask(agentID) helper.

2. TestMentionAgent_RejectsCrossWorkspaceAgentUUID's hand-crafted comment
   INSERT was missing comment.workspace_id, which migration 025 made
   NOT NULL. Pass testWorkspaceID into the seed row.

Build + vet clean locally; both packages compile.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 12:35:52 +08:00
Jiang Bohan
ee3dd1dfe1 test(handler): seed X-Task-ID alongside X-Agent-ID in existing agent-caller tests
After tightening resolveActor to require both headers (X-Agent-ID +
X-Task-ID) for the "agent" actor identity, three existing tests that
set only X-Agent-ID started failing because their requests now resolve
to "member" instead of "agent". Add createHandlerTestTaskForAgent
helper and seed a task per agent-caller assertion. Also patch
TestAgentExplicitMentionStillTriggers — it still passed only because
the @mention path doesn't care about author type for member callers,
but the test claims to exercise the agent path, so make it faithful.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 12:30:12 +08:00
Jiang Bohan
317eb1726b fix(agents): close three private-agent gate bypasses found in PR review
1. X-Agent-ID forgery (resolveActor): require X-Task-ID alongside
   X-Agent-ID before trusting the agent identity. Without this a plain
   workspace member could set X-Agent-ID to any visible agent UUID and
   short-circuit the gate to "actor=agent, allow". Daemons already
   pair the two headers, so legitimate A2A traffic is unaffected.

2. Chat history read path (chat.go): GetChatSession / ListChatMessages /
   GetPendingChatTask / MarkChatSessionRead now go through a new
   gateChatSessionForUser helper that re-applies canAccessPrivateAgent
   after the ownership check, so a session creator whose role was later
   downgraded loses transcript access. ListChatSessions and
   ListPendingChatTasks filter their result sets by the same predicate.

3. Cross-workspace @mention (comment.enqueueMentionedAgentTasks):
   resolve the mentioned agent via GetAgentInWorkspace scoped to the
   issue's workspace so a UUID belonging to a different workspace's
   private agent can't slip past the gate (the gate was being applied
   against the current workspace's role table, which is the wrong
   one).

Regression tests cover each bypass, plus an update to the resolveActor
unit test to reflect the new "X-Agent-ID without X-Task-ID falls back
to member" contract.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 11:55:02 +08:00
Jiang Bohan
0d26712999 feat(agents): gate issue assignment with the private-agent predicate
Refactor validateAssigneePair to call the shared canAccessPrivateAgent
helper. This closes the back door where a plain member could assign a
private agent to an issue and let normal task dispatch run it, side-
stepping the chat / @-mention gate. Agent callers (X-Agent-ID) bypass
so A2A delegation onto a private assignee still works.

Add an integration test covering all three callers (workspace owner,
agent owner, plain member).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 13:29:36 +08:00
Jiang Bohan
ad16b99232 feat(agents): gate private-agent surfaces with allowed_principals predicate
Tighten chat/@-mention, history, edit, and delete entry points so private
agents are only reachable by their owner or workspace owner/admin. Agent-to-
agent traffic still bypasses the gate so A2A collaboration keeps working.

- New canAccessPrivateAgent predicate in handler/agent_access.go; used by
  comment.enqueueMentionedAgentTasks (replacing the inline check), GetAgent,
  ListAgents (filter), ListAgentTasks, GetWorkspaceAgentRunCounts /
  Activity30d / TaskSnapshot (workspace-wide aggregations no longer leak
  private-agent existence + counts), chat.CreateChatSession,
  chat.SendChatMessage (re-checks on every send so role changes can't leave
  a stale session as a back-door), and autopilot.shouldSkipDispatch
  (caller = autopilot creator).
- allowed_principals is computed inline as {agent.owner_id} ∪ workspace
  owner/admin members. No new table — manual config is intentionally not
  exposed in v1; the predicate is the extension seam.
- Front-end agent detail page distinguishes 403 (private agent the caller
  can't access) from 404 (deleted/missing) and renders a "no access"
  placeholder with a back-to-agents button.
- Go tests cover the pure predicate matrix + the four protected surfaces;
  vitest passes for the affected views.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 13:13:01 +08:00
14 changed files with 970 additions and 83 deletions

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import {
AlertCircle,
ArrowLeft,
Lock,
MoreHorizontal,
Trash2,
} from "lucide-react";
@@ -14,7 +15,7 @@ import {
type AgentPresenceDetail,
useWorkspacePresenceMap,
} from "@multica/core/agents";
import { api } from "@multica/core/api";
import { api, ApiError } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
@@ -78,6 +79,19 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
const presence: AgentPresenceDetail | null =
agent ? presenceMap.get(agent.id) ?? null : null;
// Fallback fetch: when the agent is missing from the workspace list, hit
// GET /api/agents/{id} directly to disambiguate "doesn't exist" (404) from
// "you can't see this private agent" (403). Only fires after the list has
// settled, so the common path makes zero extra requests.
const { error: detailError } = useQuery({
queryKey: ["agent-detail-probe", wsId, agentId],
queryFn: () => api.getAgent(agentId),
enabled: !agentsLoading && !agent && !!agentId,
retry: false,
});
const isForbidden =
detailError instanceof ApiError && detailError.status === 403;
// Permission hook MUST be called unconditionally — its `agent | null`
// signature handles the not-found / loading case internally so the early
// returns below don't violate the rules of hooks. Backend gates archive
@@ -122,6 +136,31 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
return <DetailLoadingSkeleton />;
}
// --- No permission (private agent the caller is not in allowed_principals for) ---
if (!agent && isForbidden) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<BackHeader paths={paths.agents()} title={t(($) => $.detail.back_to_agents)} />
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<Lock className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{t(($) => $.detail.no_access_title)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(($) => $.detail.no_access_hint)}
</p>
</div>
<Button
type="button"
size="sm"
onClick={() => navigation.push(paths.agents())}
>
{t(($) => $.detail.back_to_agents_full)}
</Button>
</div>
</div>
);
}
// --- Not found / error ---
if (!agent) {
return (

View File

@@ -107,6 +107,8 @@
"back_to_agents_full": "Back to agents",
"not_found_title": "Agent not found",
"not_found_default": "This agent may have been archived or deleted.",
"no_access_title": "You don't have access to this agent",
"no_access_hint": "Only the agent owner or a workspace admin can view this private agent.",
"try_again": "Try again",
"archived_banner": "This agent is archived. It cannot be assigned or mentioned.",
"restore": "Restore",

View File

@@ -103,6 +103,8 @@
"back_to_agents_full": "返回智能体列表",
"not_found_title": "未找到该智能体",
"not_found_default": "该智能体可能已被归档或删除。",
"no_access_title": "你没有访问该智能体的权限",
"no_access_hint": "只有该私密智能体的拥有者或工作区管理员可以查看。",
"try_again": "重试",
"archived_banner": "该智能体已归档,无法被分配或提及。",
"restore": "恢复",

View File

@@ -10,8 +10,12 @@ import (
"testing"
)
// authRequestWithAgent makes an authenticated request with X-Agent-ID header,
// causing the server to resolve the actor as an agent instead of a member.
// authRequestWithAgent makes an authenticated request with X-Agent-ID +
// X-Task-ID headers, causing the server to resolve the actor as an agent
// instead of a member. resolveActor requires both headers to grant agent
// identity (defense against header forgery — see #2359 PR review), so we
// seed a queued task for the agent on demand and pass its UUID as
// X-Task-ID. The task is best-effort cleaned up via test teardown elsewhere.
func authRequestWithAgent(t *testing.T, method, path string, body any, agentID string) *http.Response {
t.Helper()
var bodyReader io.Reader
@@ -27,6 +31,7 @@ func authRequestWithAgent(t *testing.T, method, path string, body any, agentID s
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", ensureAgentTask(t, agentID))
r, err := http.DefaultClient.Do(req)
if err != nil {
@@ -35,6 +40,37 @@ func authRequestWithAgent(t *testing.T, method, path string, body any, agentID s
return r
}
// ensureAgentTask returns a queued task UUID belonging to the given agent,
// inserting one if none exists. Used by authRequestWithAgent so callers
// can keep treating "set X-Agent-ID" as the single knob for posing as an
// agent — resolveActor's pair-required policy is satisfied transparently.
func ensureAgentTask(t *testing.T, agentID string) string {
t.Helper()
ctx := context.Background()
var taskID string
if err := testPool.QueryRow(ctx,
`SELECT id::text FROM agent_task_queue WHERE agent_id = $1 LIMIT 1`,
agentID,
).Scan(&taskID); err == nil && taskID != "" {
return taskID
}
var runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id::text FROM agent WHERE id = $1`,
agentID,
).Scan(&runtimeID); err != nil {
t.Fatalf("ensureAgentTask: load runtime_id for agent %s: %v", agentID, err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
VALUES ($1, $2, 'queued', 0)
RETURNING id::text
`, agentID, runtimeID).Scan(&taskID); err != nil {
t.Fatalf("ensureAgentTask: insert task for agent %s: %v", agentID, err)
}
return taskID
}
// countPendingTasks returns the number of queued/dispatched tasks for an issue.
func countPendingTasks(t *testing.T, issueID string) int {
t.Helper()

View File

@@ -289,9 +289,17 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
})
}
// All agents (including private) are visible to workspace members.
// Resolve the request actor once. Agents bypass the private-agent gate
// to preserve A2A collaboration; members must be in allowed_principals
// (agent owner or workspace owner/admin) to see private agents.
actorType, actorID := h.resolveActor(r, userID, workspaceID)
visible := make([]AgentResponse, 0, len(agents))
for _, a := range agents {
if a.Visibility == "private" && actorType == "member" {
if !memberAllowedForPrivateAgent(a, actorID, member.Role) {
continue
}
}
resp := agentToResponse(a)
if skills, ok := skillMap[resp.ID]; ok {
resp.Skills = skills
@@ -313,6 +321,16 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
// Private-agent gate: members must be in allowed_principals to view
// (and therefore navigate to) a private agent. The 403 lets the front-end
// render an explicit "no access" placeholder instead of a 404 — see
// agent-detail-page.tsx.
workspaceID := uuidToString(agent.WorkspaceID)
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this agent")
return
}
resp := agentToResponse(agent)
// Use the summary query (no `content` column) — the embedded
// AgentSkillSummary only needs id/name/description, and reading large
@@ -814,6 +832,14 @@ func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
// Run history is part of the private-agent gate ("查看历史会话"). Same
// 403 semantics as GetAgent.
workspaceID := uuidToString(agent.WorkspaceID)
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this agent")
return
}
tasks, err := h.Queries.ListAgentTasks(r.Context(), agent.ID)
if err != nil {
@@ -850,7 +876,8 @@ type AgentRunCount struct {
// activity to keep the Agents list cheap regardless of agent count.
func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
@@ -860,12 +887,23 @@ func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Requ
return
}
resp := make([]AgentRunCount, len(rows))
for i, row := range rows {
resp[i] = AgentRunCount{
AgentID: uuidToString(row.AgentID),
RunCount: row.RunCount,
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
resp := make([]AgentRunCount, 0, len(rows))
for _, row := range rows {
agentID := uuidToString(row.AgentID)
if _, ok := allowed[agentID]; !ok {
continue
}
resp = append(resp, AgentRunCount{
AgentID: agentID,
RunCount: row.RunCount,
})
}
writeJSON(w, http.StatusOK, resp)
@@ -879,7 +917,8 @@ func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Requ
// empty buckets to keep the response small.
func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
@@ -889,14 +928,25 @@ func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Re
return
}
resp := make([]AgentActivityBucket, len(rows))
for i, row := range rows {
resp[i] = AgentActivityBucket{
AgentID: uuidToString(row.AgentID),
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
resp := make([]AgentActivityBucket, 0, len(rows))
for _, row := range rows {
agentID := uuidToString(row.AgentID)
if _, ok := allowed[agentID]; !ok {
continue
}
resp = append(resp, AgentActivityBucket{
AgentID: agentID,
BucketAt: timestampToString(row.Bucket),
TaskCount: row.TaskCount,
FailedCount: row.FailedCount,
}
})
}
writeJSON(w, http.StatusOK, resp)
@@ -913,7 +963,8 @@ func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Re
// snapshot.
func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
@@ -923,9 +974,19 @@ func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.
return
}
resp := make([]AgentTaskResponse, len(tasks))
for i, t := range tasks {
resp[i] = taskToResponse(t)
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
resp := make([]AgentTaskResponse, 0, len(tasks))
for _, t := range tasks {
if _, ok := allowed[uuidToString(t.AgentID)]; !ok {
continue
}
resp = append(resp, taskToResponse(t))
}
writeJSON(w, http.StatusOK, resp)

View File

@@ -0,0 +1,75 @@
package handler
import (
"context"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// canAccessPrivateAgent gates the four protected surfaces for private
// agents: chat / @-mention dispatch, viewing the agent's history, editing
// configuration, and deletion.
//
// Public agents are unrestricted — the predicate returns true unconditionally.
//
// Agent-to-agent traffic is always allowed (actorType == "agent"); this is
// what preserves A2A collaboration even with private agents. The trust
// boundary is at member↔agent, not agent↔agent.
//
// For members, the implicit allowed_principals set is computed inline as:
// {agent.owner_id} workspace owner/admin members. Manual configuration of
// allowed_principals is not exposed in v1; future work can extend this set
// without changing call sites.
func (h *Handler) canAccessPrivateAgent(ctx context.Context, agent db.Agent, actorType, actorID, workspaceID string) bool {
if agent.Visibility != "private" {
return true
}
if actorType == "agent" {
return true
}
if uuidToString(agent.OwnerID) == actorID {
return true
}
member, err := h.getWorkspaceMember(ctx, actorID, workspaceID)
if err != nil {
return false
}
return roleAllowed(member.Role, "owner", "admin")
}
// memberAllowedForPrivateAgent is the pure predicate used by both
// canAccessPrivateAgent and the ListAgents filter loop. Caller must have
// already confirmed agent.Visibility == "private".
func memberAllowedForPrivateAgent(agent db.Agent, userID, role string) bool {
if roleAllowed(role, "owner", "admin") {
return true
}
return uuidToString(agent.OwnerID) == userID
}
// accessibleAgentIDs returns the set of agent IDs in the workspace the actor
// is allowed to see, for use by workspace-wide aggregation endpoints
// (run counts, activity histograms, task snapshots) that need to filter out
// private agents the member can't access. Returns nil and false on error.
func (h *Handler) accessibleAgentIDs(ctx context.Context, workspaceID, actorType, actorID, role string) (map[string]struct{}, bool) {
wsUUID, err := util.ParseUUID(workspaceID)
if err != nil {
return nil, false
}
agents, err := h.Queries.ListAllAgents(ctx, wsUUID)
if err != nil {
return nil, false
}
allowed := make(map[string]struct{}, len(agents))
for _, a := range agents {
if a.Visibility == "private" && actorType == "member" {
if !memberAllowedForPrivateAgent(a, actorID, role) {
continue
}
}
allowed[uuidToString(a.ID)] = struct{}{}
}
return allowed, true
}

View File

@@ -0,0 +1,503 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestMemberAllowedForPrivateAgent_Pure exercises the pure predicate that
// drives the private-agent gate. The gate must allow:
// - workspace owner / admin (regardless of agent ownership)
// - the agent owner (regardless of role)
//
// And deny everyone else. This test runs without a database.
func TestMemberAllowedForPrivateAgent_Pure(t *testing.T) {
ownerUserID := "11111111-1111-1111-1111-111111111111"
otherUserID := "22222222-2222-2222-2222-222222222222"
agent := db.Agent{
OwnerID: util.MustParseUUID(ownerUserID),
}
cases := []struct {
name string
userID string
role string
want bool
}{
{"workspace owner, not agent owner", otherUserID, "owner", true},
{"workspace admin, not agent owner", otherUserID, "admin", true},
{"agent owner with member role", ownerUserID, "member", true},
{"agent owner with admin role", ownerUserID, "admin", true},
{"plain member, not agent owner", otherUserID, "member", false},
{"plain member with no role string", otherUserID, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := memberAllowedForPrivateAgent(agent, tc.userID, tc.role)
if got != tc.want {
t.Fatalf("memberAllowedForPrivateAgent(userID=%s, role=%s) = %v; want %v",
tc.userID, tc.role, got, tc.want)
}
})
}
}
// privateAgentTestFixture sets up a private agent owned by a freshly created
// user, plus a second non-admin member in the workspace. Returns the agent
// id, the owner's user id, and the unrelated member's user id. The caller's
// own testUserID stays workspace owner so it can act as the privileged
// admin path.
func privateAgentTestFixture(t *testing.T) (agentID, ownerID, memberID string) {
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ('Private Agent Owner', 'private-agent-owner@multica.test')
RETURNING id
`).Scan(&ownerID); err != nil {
t.Fatalf("create owner user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM "user" WHERE email = 'private-agent-owner@multica.test'`)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'member')
`, testWorkspaceID, ownerID); err != nil {
t.Fatalf("add owner as member: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ('Plain Member', 'plain-member@multica.test')
RETURNING id
`).Scan(&memberID); err != nil {
t.Fatalf("create plain member user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM "user" WHERE email = 'plain-member@multica.test'`)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'member')
`, testWorkspaceID, memberID); err != nil {
t.Fatalf("add plain member: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions, custom_env, custom_args
)
VALUES ($1, 'private-access-test-agent', '', 'cloud', '{}'::jsonb,
$2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
RETURNING id
`, testWorkspaceID, handlerTestRuntimeID(t), ownerID).Scan(&agentID); err != nil {
t.Fatalf("create private agent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM agent WHERE id = $1`, agentID)
})
return agentID, ownerID, memberID
}
func newRequestAs(userID, method, path string, body any) *http.Request {
req := newRequest(method, path, body)
req.Header.Set("X-User-ID", userID)
return req
}
// TestGetAgent_PrivateAgentForbidsPlainMember verifies the private-agent
// visibility gate at the read-detail endpoint: a workspace member who is
// neither the agent owner nor a workspace owner/admin gets 403, while the
// agent owner and workspace owner both succeed. Mirrors the four-entry-point
// gate (chat, history, edit, delete) on its read surface.
func TestGetAgent_PrivateAgentForbidsPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, ownerID, memberID := privateAgentTestFixture(t)
// Workspace owner (testUserID): allowed via role.
w := httptest.NewRecorder()
testHandler.GetAgent(w, withURLParam(newRequest("GET", "/api/agents/"+agentID, nil), "id", agentID))
if w.Code != http.StatusOK {
t.Fatalf("GetAgent as workspace owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Agent owner (plain member who happens to own the agent): allowed.
w = httptest.NewRecorder()
testHandler.GetAgent(w, withURLParam(newRequestAs(ownerID, "GET", "/api/agents/"+agentID, nil), "id", agentID))
if w.Code != http.StatusOK {
t.Fatalf("GetAgent as agent owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Plain member (not in allowed_principals): denied with 403.
w = httptest.NewRecorder()
testHandler.GetAgent(w, withURLParam(newRequestAs(memberID, "GET", "/api/agents/"+agentID, nil), "id", agentID))
if w.Code != http.StatusForbidden {
t.Fatalf("GetAgent as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestListAgents_FiltersPrivateForPlainMember verifies that the workspace
// agents listing hides private agents from members who lack access. This is
// what makes the @-mention autocomplete picker (which feeds off this list)
// drop unreachable private agents without any client-side logic.
func TestListAgents_FiltersPrivateForPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, _, memberID := privateAgentTestFixture(t)
// Workspace owner sees the agent.
w := httptest.NewRecorder()
testHandler.ListAgents(w, newRequest("GET", "/api/agents", nil))
if w.Code != http.StatusOK {
t.Fatalf("ListAgents as owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
if !listContainsAgent(t, w.Body.Bytes(), agentID) {
t.Fatalf("ListAgents as owner did not include private agent %s", agentID)
}
// Plain member does NOT see the agent.
w = httptest.NewRecorder()
testHandler.ListAgents(w, newRequestAs(memberID, "GET", "/api/agents", nil))
if w.Code != http.StatusOK {
t.Fatalf("ListAgents as plain member: expected 200, got %d: %s", w.Code, w.Body.String())
}
if listContainsAgent(t, w.Body.Bytes(), agentID) {
t.Fatalf("ListAgents as plain member leaked private agent %s", agentID)
}
}
func listContainsAgent(t *testing.T, body []byte, agentID string) bool {
t.Helper()
var resp []AgentResponse
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("decode ListAgents response: %v", err)
}
for _, a := range resp {
if a.ID == agentID {
return true
}
}
return false
}
// TestListAgentTasks_PrivateAgentForbidsPlainMember verifies that the agent
// task history endpoint (the "查看历史会话" surface) is also gated.
func TestListAgentTasks_PrivateAgentForbidsPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, ownerID, memberID := privateAgentTestFixture(t)
w := httptest.NewRecorder()
testHandler.ListAgentTasks(w, withURLParam(newRequestAs(ownerID, "GET", "/api/agents/"+agentID+"/tasks", nil), "id", agentID))
if w.Code != http.StatusOK {
t.Fatalf("ListAgentTasks as owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
testHandler.ListAgentTasks(w, withURLParam(newRequestAs(memberID, "GET", "/api/agents/"+agentID+"/tasks", nil), "id", agentID))
if w.Code != http.StatusForbidden {
t.Fatalf("ListAgentTasks as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateIssue_AssignToPrivateAgentForbidsPlainMember verifies that the
// issue-assignment surface is gated by the same predicate. Without this gate
// a plain workspace member could side-step chat/@-mention by assigning a
// private agent to an issue and letting normal task dispatch run it.
func TestCreateIssue_AssignToPrivateAgentForbidsPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, ownerID, memberID := privateAgentTestFixture(t)
body := func(actorID string) map[string]any {
return map[string]any{
"title": "assign-to-private-agent test " + actorID,
"status": "todo",
"priority": "medium",
"assignee_type": "agent",
"assignee_id": agentID,
}
}
// Workspace owner (testUserID): allowed.
w := httptest.NewRecorder()
testHandler.CreateIssue(w, newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, body(testUserID)))
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue as workspace owner: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Agent owner (plain member who happens to own the agent): allowed.
w = httptest.NewRecorder()
testHandler.CreateIssue(w, newRequestAs(ownerID, "POST", "/api/issues?workspace_id="+testWorkspaceID, body(ownerID)))
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue as agent owner: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Plain member: denied with 403 — closes the back door where issue
// assignment would otherwise hand the agent a task without going
// through chat / @-mention.
w = httptest.NewRecorder()
testHandler.CreateIssue(w, newRequestAs(memberID, "POST", "/api/issues?workspace_id="+testWorkspaceID, body(memberID)))
if w.Code != http.StatusForbidden {
t.Fatalf("CreateIssue as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateChatSession_PrivateAgentForbidsPlainMember verifies that members
// who can't access the private agent cannot start a chat session against it.
// The chat handler reads workspace context from middleware, so we set it
// explicitly via middleware.SetMemberContext before invoking the handler
// (the test harness doesn't run the real middleware chain).
func TestCreateChatSession_PrivateAgentForbidsPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, _, memberID := privateAgentTestFixture(t)
// Load the plain member's row so we can build a realistic context.
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
UserID: util.MustParseUUID(memberID),
WorkspaceID: util.MustParseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("load plain member row: %v", err)
}
body := map[string]any{
"agent_id": agentID,
"title": "should be denied",
}
w := httptest.NewRecorder()
req := newRequestAs(memberID, "POST", "/api/chat/sessions", body)
req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow))
testHandler.CreateChatSession(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("CreateChatSession as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestGetAgent_RejectsForgedAgentIDHeader is the regression test for the
// #2359 review finding "X-Agent-ID can be forged by a plain member to bypass
// the private gate". A workspace member sets X-Agent-ID to any visible
// agent's UUID without supplying a valid X-Task-ID — resolveActor must now
// fall back to the member identity, so the private-agent gate stays effective.
func TestGetAgent_RejectsForgedAgentIDHeader(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, _, memberID := privateAgentTestFixture(t)
w := httptest.NewRecorder()
req := newRequestAs(memberID, "GET", "/api/agents/"+agentID, nil)
// Forge X-Agent-ID without X-Task-ID. Pre-fix this would have made
// resolveActor return ("agent", agentID) and canAccessPrivateAgent
// would have unconditionally allowed the read.
req.Header.Set("X-Agent-ID", agentID)
req = withURLParam(req, "id", agentID)
testHandler.GetAgent(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("GetAgent with forged X-Agent-ID: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestListChatMessages_PrivateAgentForbidsAfterAccessRevoked is the regression
// test for the #2359 review finding "chat history read path doesn't re-gate".
// A member who created a chat session is later denied access to the agent
// (here simulated by the member never being on the allowlist for a private
// agent owned by someone else; the equivalent of an after-the-fact ownership
// transfer). The session row still names them as creator, but the read
// endpoints must refuse to surface the transcript.
func TestListChatMessages_PrivateAgentForbidsAfterAccessRevoked(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
// Insert a chat session row directly with the plain member as creator,
// bypassing CreateChatSession's own gate. This represents a session
// that existed before the member lost access (or before the gate
// landed).
var sessionID string
if err := testPool.QueryRow(ctx, `
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
VALUES ($1, $2, $3, 'pre-revocation session', 'active')
RETURNING id
`, testWorkspaceID, agentID, memberID).Scan(&sessionID); err != nil {
t.Fatalf("seed chat session: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, sessionID)
})
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: util.MustParseUUID(memberID),
WorkspaceID: util.MustParseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("load plain member row: %v", err)
}
w := httptest.NewRecorder()
req := newRequestAs(memberID, "GET", "/api/chat/sessions/"+sessionID+"/messages", nil)
req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow))
req = withURLParam(req, "sessionId", sessionID)
testHandler.ListChatMessages(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("ListChatMessages on stale session: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestMentionAgent_RejectsCrossWorkspaceAgentUUID is the regression test for
// the #2359 review finding "@mention path doesn't constrain the mentioned
// agent to the current workspace". A plain member in workspace A who happens
// to be owner of workspace B should NOT be able to @mention a private agent
// in workspace B from a comment on a workspace-A issue and have it pass the
// gate (the gate was being applied against the wrong workspace's roles).
func TestMentionAgent_RejectsCrossWorkspaceAgentUUID(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Create a separate workspace + agent runtime + private agent.
var foreignWorkspaceID, foreignUserID, foreignRuntimeID, foreignAgentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ('Foreign Owner', 'cross-ws-foreign@multica.test')
RETURNING id
`).Scan(&foreignUserID); err != nil {
t.Fatalf("create foreign user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM "user" WHERE email = 'cross-ws-foreign@multica.test'`)
})
if err := testPool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description, issue_prefix)
VALUES ('Cross-WS Foreign', 'cross-ws-foreign', '', 'XWF')
RETURNING id
`).Scan(&foreignWorkspaceID); err != nil {
t.Fatalf("create foreign workspace: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM workspace WHERE slug = 'cross-ws-foreign'`)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
`, foreignWorkspaceID, foreignUserID); err != nil {
t.Fatalf("add foreign member: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at)
VALUES ($1, NULL, 'Foreign Runtime', 'cloud', 'foreign_test', 'online', 'Foreign', '{}'::jsonb, now())
RETURNING id
`, foreignWorkspaceID).Scan(&foreignRuntimeID); err != nil {
t.Fatalf("create foreign runtime: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id, instructions, custom_env, custom_args)
VALUES ($1, 'foreign-private-agent', '', 'cloud', '{}'::jsonb, $2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
RETURNING id
`, foreignWorkspaceID, foreignRuntimeID, foreignUserID).Scan(&foreignAgentID); err != nil {
t.Fatalf("create foreign agent: %v", err)
}
// Create an issue in OUR workspace and a comment that @mentions the
// foreign agent's UUID. testUserID is owner of our workspace; pre-fix
// the gate would have applied our-workspace-owner status to the foreign
// agent and enqueued a task.
var issueID, commentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number)
VALUES ($1, 'cross-ws mention test', 'todo', 'medium', 'member', $2,
COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
t.Fatalf("create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
// Multica's mention format is markdown-linked: [@Name](mention://agent/<uuid>).
mention := "[@Foreign](mention://agent/" + foreignAgentID + ")"
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content)
VALUES ($1, $2, 'member', $3, $4)
RETURNING id
`, testWorkspaceID, issueID, testUserID, mention).Scan(&commentID); err != nil {
t.Fatalf("create test comment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM comment WHERE id = $1`, commentID)
})
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
if err != nil {
t.Fatalf("load test issue: %v", err)
}
comment, err := testHandler.Queries.GetComment(ctx, util.MustParseUUID(commentID))
if err != nil {
t.Fatalf("load test comment: %v", err)
}
// Count tasks for the foreign agent before. Calling the dispatcher
// directly bypasses HTTP-layer concerns and exercises only the
// workspace-scoping check.
var beforeCount int
if err := testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM agent_task_queue WHERE agent_id = $1`,
foreignAgentID,
).Scan(&beforeCount); err != nil {
t.Fatalf("count tasks before: %v", err)
}
testHandler.enqueueMentionedAgentTasks(ctx, issue, comment, nil, "member", testUserID)
var afterCount int
if err := testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM agent_task_queue WHERE agent_id = $1`,
foreignAgentID,
).Scan(&afterCount); err != nil {
t.Fatalf("count tasks after: %v", err)
}
if afterCount != beforeCount {
t.Fatalf("foreign agent task count changed: before=%d after=%d — cross-workspace mention was not rejected",
beforeCount, afterCount)
}
}

View File

@@ -60,6 +60,14 @@ func (h *Handler) CreateChatSession(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "agent is archived")
return
}
// Private-agent gate: members must be in allowed_principals to start
// a chat with a private agent. Agent-to-agent chat sessions bypass
// the gate so A2A collaboration still works.
actorType, actorID := h.resolveActor(r, userID, workspaceID)
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this agent")
return
}
session, err := h.Queries.CreateChatSession(r.Context(), db.CreateChatSessionParams{
WorkspaceID: workspaceUUID,
@@ -82,6 +90,23 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
}
workspaceID := ctxWorkspaceID(r.Context())
// Compute the accessible-agents set once and use it to drop sessions
// whose target agent the caller no longer has access to — without this,
// a member whose role was downgraded would still see the session list
// (and transcripts via ListChatMessages) for any private agent they
// previously had access to. Falls back to the user's role from the
// workspace member context.
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
status := r.URL.Query().Get("status")
// Two call sites → two row types with identical shape. Collect into a
@@ -96,9 +121,12 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
return
}
resp = make([]ChatSessionResponse, len(rows))
for i, s := range rows {
resp[i] = ChatSessionResponse{
resp = make([]ChatSessionResponse, 0, len(rows))
for _, s := range rows {
if _, ok := allowed[uuidToString(s.AgentID)]; !ok {
continue
}
resp = append(resp, ChatSessionResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
AgentID: uuidToString(s.AgentID),
@@ -108,7 +136,7 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
HasUnread: s.HasUnread,
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
}
})
}
} else {
rows, err := h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
@@ -119,9 +147,12 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
return
}
resp = make([]ChatSessionResponse, len(rows))
for i, s := range rows {
resp[i] = ChatSessionResponse{
resp = make([]ChatSessionResponse, 0, len(rows))
for _, s := range rows {
if _, ok := allowed[uuidToString(s.AgentID)]; !ok {
continue
}
resp = append(resp, ChatSessionResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
AgentID: uuidToString(s.AgentID),
@@ -131,7 +162,7 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
HasUnread: s.HasUnread,
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
}
})
}
}
writeJSON(w, http.StatusOK, resp)
@@ -161,6 +192,29 @@ func (h *Handler) loadChatSessionForUser(w http.ResponseWriter, r *http.Request,
return session, true
}
// gateChatSessionForUser combines the session ownership check with the
// private-agent access gate so a member who has lost access to the target
// agent (role downgrade, ownership transfer, agent flipped to private)
// cannot continue reading the chat transcript even though they remain the
// session creator. Returns ok=false after writing the error response.
func (h *Handler) gateChatSessionForUser(w http.ResponseWriter, r *http.Request, userID, workspaceID, sessionID string) (db.ChatSession, bool) {
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return db.ChatSession{}, false
}
agent, err := h.Queries.GetAgent(r.Context(), session.AgentID)
if err != nil {
writeError(w, http.StatusNotFound, "agent not found")
return db.ChatSession{}, false
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this agent")
return db.ChatSession{}, false
}
return session, true
}
func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -169,7 +223,7 @@ func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
@@ -283,8 +337,12 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
return
}
// Load chat session.
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
// Load chat session and re-check the private-agent gate on every send.
// The session's creator passed the gate at create time, but their
// workspace role (or the agent's owner) may have changed since — keep
// stale sessions from being a back-door into a private agent the user
// can no longer reach. Agent senders bypass to preserve A2A collaboration.
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
@@ -356,7 +414,7 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
@@ -397,7 +455,7 @@ func (h *Handler) MarkChatSessionRead(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
@@ -428,7 +486,8 @@ type PendingChatTaskItem struct {
// ListPendingChatTasks returns every in-flight chat task owned by the current
// user in this workspace. Drives the FAB's "running" indicator when the chat
// window is closed (no per-session query is subscribed).
// window is closed (no per-session query is subscribed). Tasks belonging to
// private agents the caller has lost access to are dropped from the response.
func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -436,6 +495,17 @@ func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
}
workspaceID := ctxWorkspaceID(r.Context())
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
rows, err := h.Queries.ListPendingChatTasksByCreator(r.Context(), db.ListPendingChatTasksByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
@@ -445,13 +515,37 @@ func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
return
}
items := make([]PendingChatTaskItem, len(rows))
for i, row := range rows {
items[i] = PendingChatTaskItem{
// Map session → agent so we can filter without an N+1. The user's own
// session list is small, so one extra query is cheaper than per-row
// lookups.
sessions, err := h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve chat session agents")
return
}
sessionAgent := make(map[string]string, len(sessions))
for _, s := range sessions {
sessionAgent[uuidToString(s.ID)] = uuidToString(s.AgentID)
}
items := make([]PendingChatTaskItem, 0, len(rows))
for _, row := range rows {
sessionID := uuidToString(row.ChatSessionID)
agentID, hasAgent := sessionAgent[sessionID]
if !hasAgent {
continue
}
if _, ok := allowed[agentID]; !ok {
continue
}
items = append(items, PendingChatTaskItem{
TaskID: uuidToString(row.TaskID),
Status: row.Status,
ChatSessionID: uuidToString(row.ChatSessionID),
}
ChatSessionID: sessionID,
})
}
writeJSON(w, http.StatusOK, PendingChatTasksResponse{Tasks: items})
}
@@ -467,7 +561,7 @@ func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}

View File

@@ -426,20 +426,23 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
continue
}
agentUUID := parseUUID(m.ID)
// Load the agent to check visibility, archive status, and trigger config.
agent, err := h.Queries.GetAgent(ctx, agentUUID)
// Load the agent scoped to the current issue's workspace. Using the
// bare GetAgent here would let a mention resolve to an agent in a
// different workspace, and the visibility check below would then be
// applied against the wrong workspace's roles (a workspace owner in
// THIS workspace would pass the gate for a private agent that lives
// in someone else's workspace).
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: agentUUID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
continue
}
// Private agents can only be mentioned by the agent owner or workspace admin/owner.
if agent.Visibility == "private" && authorType == "member" {
isOwner := uuidToString(agent.OwnerID) == authorID
if !isOwner {
member, err := h.getWorkspaceMember(ctx, authorID, wsID)
if err != nil || !roleAllowed(member.Role, "owner", "admin") {
continue
}
}
// Private-agent gate (member→private requires allowed_principals;
// agent→agent always passes).
if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) {
continue
}
// Dedup: skip if this agent already has a pending task for this issue.
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{

View File

@@ -235,15 +235,29 @@ func requestUserID(r *http.Request) string {
}
// resolveActor determines whether the request is from an agent or a human member.
// If X-Agent-ID and X-Task-ID headers are both set, validates that the task
// belongs to the claimed agent (defense-in-depth against manual header spoofing).
// If only X-Agent-ID is set, validates that the agent belongs to the workspace.
// To claim "agent" identity the request MUST carry both X-Agent-ID and a valid
// X-Task-ID, and the task must belong to the claimed agent. Otherwise we fall
// back to "member" using the user ID from the session.
//
// X-Agent-ID alone is not trusted: any workspace member can guess or observe
// an agent's UUID, and a member-supplied X-Agent-ID would otherwise let that
// member impersonate the agent and bypass the private-agent gate (#2359
// review). The daemon always pairs the two headers — X-Agent-ID names the
// agent claiming the request, X-Task-ID names the in-flight task that
// authorizes it — so requiring both has no effect on legitimate agent
// callers but closes the impersonation path.
//
// Returns ("agent", agentID) on success, ("member", userID) otherwise.
func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (actorType, actorID string) {
agentID := r.Header.Get("X-Agent-ID")
if agentID == "" {
return "member", userID
}
taskID := r.Header.Get("X-Task-ID")
if taskID == "" {
slog.Debug("resolveActor: X-Agent-ID present but X-Task-ID missing, refusing to trust agent identity", "agent_id", agentID)
return "member", userID
}
agentUUID, err := util.ParseUUID(agentID)
if err != nil {
@@ -257,18 +271,15 @@ func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (act
return "member", userID
}
// When X-Task-ID is provided, cross-check that the task belongs to this agent.
if taskID := r.Header.Get("X-Task-ID"); taskID != "" {
taskUUID, err := util.ParseUUID(taskID)
if err != nil {
slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID)
return "member", userID
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil || uuidToString(task.AgentID) != agentID {
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
return "member", userID
}
taskUUID, err := util.ParseUUID(taskID)
if err != nil {
slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID)
return "member", userID
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil || uuidToString(task.AgentID) != agentID {
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
return "member", userID
}
return "agent", agentID

View File

@@ -198,6 +198,27 @@ func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string
return agentID
}
// createHandlerTestTaskForAgent seeds a queued agent_task_queue row for the
// given agent and returns the task UUID. Used by tests that need to set
// X-Task-ID alongside X-Agent-ID — resolveActor now requires the pair to be
// present and consistent before granting "agent" actor identity.
func createHandlerTestTaskForAgent(t *testing.T, agentID string) string {
t.Helper()
var taskID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
VALUES ($1, $2, 'queued', 0)
RETURNING id
`, agentID, handlerTestRuntimeID(t)).Scan(&taskID); err != nil {
t.Fatalf("failed to create handler test task: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
})
return taskID
}
func fetchAgentMcpConfig(t *testing.T, agentID string) []byte {
t.Helper()
@@ -1817,14 +1838,18 @@ func TestResolveActor(t *testing.T) {
wantActorType: "member",
},
{
name: "valid agent ID returns agent",
// X-Agent-ID without X-Task-ID is not trusted — otherwise a
// workspace member who guesses an agent's UUID could impersonate
// it and bypass the private-agent gate. See resolveActor for the
// rationale.
name: "agent ID without task ID returns member",
agentIDHeader: agentID,
wantActorType: "agent",
wantIsAgent: true,
wantActorType: "member",
},
{
name: "non-existent agent ID returns member",
name: "non-existent agent ID with task returns member",
agentIDHeader: "00000000-0000-0000-0000-000000000099",
taskIDHeader: taskID,
wantActorType: "member",
},
{
@@ -2088,10 +2113,13 @@ func TestAgentReplyDoesNotInheritParentMentions(t *testing.T) {
// 3. Agent A posts a reply in the same thread with NO mentions.
// With the fix, this must NOT inherit the parent mention of Agent B.
// resolveActor requires X-Task-ID paired with X-Agent-ID to trust the
// agent identity, so we seed a task that belongs to agent A.
agentATask := createHandlerTestTaskForAgent(t, agentA)
w = postComment(issueID, map[string]any{
"content": "No reply needed — just an acknowledgment.",
"parent_id": parentComment.ID,
}, map[string]string{"X-Agent-ID": agentA})
}, map[string]string{"X-Agent-ID": agentA, "X-Task-ID": agentATask})
if w.Code != http.StatusCreated {
t.Fatalf("agent A reply: expected 201, got %d: %s", w.Code, w.Body.String())
}
@@ -2164,12 +2192,16 @@ func TestMemberReplyToAgentRootDoesNotInheritParentMentions(t *testing.T) {
// 1. Agent J posts a PR-completion comment that @mentions Reviewer for review.
// This is a deliberate handoff and must enqueue a task for Reviewer.
// X-Task-ID is required alongside X-Agent-ID for resolveActor to grant
// the "agent" actor identity (defense against header forgery).
jAgentTask := createHandlerTestTaskForAgent(t, jAgent)
w = httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": fmt.Sprintf("PR ready. [@Reviewer](mention://agent/%s) please review this.", reviewerAgent),
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", jAgent)
r.Header.Set("X-Task-ID", jAgentTask)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("J PR completion: expected 201, got %d: %s", w.Code, w.Body.String())
@@ -2255,7 +2287,10 @@ func TestAgentExplicitMentionStillTriggers(t *testing.T) {
// Agent A posts a top-level comment that explicitly @mentions Agent B —
// a deliberate handoff. This must enqueue a task for Agent B, and must
// not enqueue a self-trigger for Agent A.
// not enqueue a self-trigger for Agent A. resolveActor requires
// X-Task-ID to grant "agent" identity; without it the self-trigger
// suppression (authorType=="agent") would not fire.
agentATask := createHandlerTestTaskForAgent(t, agentA)
explicitMention := fmt.Sprintf("[@Agent B](mention://agent/%s) please take it from here", agentB)
w = httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
@@ -2263,6 +2298,7 @@ func TestAgentExplicitMentionStillTriggers(t *testing.T) {
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", agentA)
r.Header.Set("X-Task-ID", agentATask)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("agent A handoff: expected 201, got %d: %s", w.Code, w.Body.String())

View File

@@ -1584,9 +1584,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
// validateAssigneePair verifies the (assignee_type, assignee_id) pair refers
// to an existing entity in the workspace. For agent assignees it also enforces
// visibility (private agents are only assignable by their owner or by
// workspace admins/owners) and rejects archived agents.
// to an existing entity in the workspace. For agent assignees it also rejects
// archived agents and runs the private-agent gate via canAccessPrivateAgent
// — assigning an issue is a task-producing surface, so it must use the same
// predicate as chat / @-mention / history. Agent callers (X-Agent-ID) bypass
// the gate so A2A flows can still hand work off to private agents.
//
// Returns (statusCode, errorMessage). statusCode == 0 means the pair is valid;
// callers should treat any non-zero status as a rejection and surface it back
@@ -1624,14 +1626,9 @@ func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, wor
if agent.ArchivedAt.Valid {
return http.StatusBadRequest, "cannot assign to archived agent"
}
if agent.Visibility == "private" {
userID := requestUserID(r)
if uuidToString(agent.OwnerID) != userID {
member, err := h.getWorkspaceMember(ctx, userID, workspaceID)
if err != nil || !roleAllowed(member.Role, "owner", "admin") {
return http.StatusForbidden, "cannot assign to private agent"
}
}
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
if !h.canAccessPrivateAgent(ctx, agent, actorType, actorID, workspaceID) {
return http.StatusForbidden, "cannot assign to private agent"
}
return 0, ""
default:

View File

@@ -236,10 +236,14 @@ func TestSubscriberAPI(t *testing.T) {
// Subscribe with X-Agent-ID set — no body, so the handler must default
// to subscribing the agent itself (not the member behind X-User-ID).
// resolveActor requires X-Task-ID alongside X-Agent-ID to grant the
// "agent" identity (defense against header forgery), so seed a task.
agentTask := createHandlerTestTaskForAgent(t, agentID)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", agentTask)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SubscribeToIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
@@ -270,10 +274,13 @@ func TestSubscriberAPI(t *testing.T) {
}
// Unsubscribe with X-Agent-ID set — same default-to-caller expectation.
// Re-use the same task as the subscribe call; resolveActor only
// validates that the task belongs to the agent, not which task.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
req = withURLParam(req, "id", issueID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", agentTask)
testHandler.UnsubscribeFromIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UnsubscribeFromIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())

View File

@@ -383,6 +383,27 @@ func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopil
if rt.Status != "online" {
return "agent runtime is " + rt.Status + " at dispatch time", true
}
// Private-agent gate at the autopilot layer. Caller identity = the
// autopilot's creator: if the creator no longer has access to the
// (now-private) target agent, the dispatch is recorded as `skipped`.
// Agent-created autopilots bypass the gate to preserve A2A
// collaboration. Errors loading the workspace member fail closed —
// without an authoritative role the gate cannot grant access.
if agent.Visibility == "private" && ap.CreatedByType == "member" {
creatorID := util.UUIDToString(ap.CreatedByID)
if util.UUIDToString(agent.OwnerID) != creatorID {
member, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: ap.CreatedByID,
WorkspaceID: ap.WorkspaceID,
})
if err != nil {
return "autopilot creator no longer in workspace", true
}
if member.Role != "owner" && member.Role != "admin" {
return "autopilot creator lacks access to private assignee agent", true
}
}
}
return "", false
}