Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions server/cmd/multica/cmd_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ func init() {
// agent list
agentListCmd.Flags().String("output", "table", "Output format: table or json")
agentListCmd.Flags().Bool("include-archived", false, "Include archived agents")
// --all opts out of the squad-leader scoping. When this CLI is invoked
// from inside a daemon-managed agent task that is a squad-leader run
// on a squad-assigned issue, `agent list` defaults to listing only that
// squad's members + the leader — `--all` returns the workspace-wide list
// instead (matching the pre-fix behavior). Has no effect outside a
// leader task. See server/internal/handler/agent.go ListAgents +
// taskSquadMemberSet for the matching server-side scope.
agentListCmd.Flags().Bool("all", false, "Return the full workspace agent list even inside a squad-leader task (default scopes to the squad's roster)")

// agent get
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
Expand Down Expand Up @@ -289,6 +297,13 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
if v, _ := cmd.Flags().GetBool("include-archived"); v {
params.Set("include_archived", "true")
}
// Squad-leader scoping: inside a daemon-managed agent task, the server
// narrows the list to the issue's squad iff the task is a leader task.
// Outside such a task the param is a no-op, so always passing it is
// safe. `--all` opts out.
if allFlag, _ := cmd.Flags().GetBool("all"); !allFlag && inAgentExecutionContext() {
params.Set("scope", "task_squad")
}
path := "/api/agents"
if len(params) > 0 {
path += "?" + params.Encode()
Expand Down
106 changes: 106 additions & 0 deletions server/cmd/multica/cmd_agent_list_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package main

import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/spf13/cobra"
)

// freshAgentListCmd mirrors agentListCmd's flag wiring for use in tests so
// each subtest gets a clean flag state. Keeps the production command's
// cobra.Command immutable across runs.
func freshAgentListCmd() *cobra.Command {
c := &cobra.Command{Use: "list"}
c.Flags().String("output", "json", "Output format")
c.Flags().Bool("include-archived", false, "Include archived agents")
c.Flags().Bool("all", false, "Return the full workspace agent list even inside a squad-leader task")
c.PersistentFlags().String("profile", "", "")
return c
}

// captureListAgentsQuery starts a stub server that records the
// /api/agents request's RawQuery and returns an empty agent list. Caller
// gets back the captured query and the server's URL.
func captureListAgentsQuery(t *testing.T) (*httptest.Server, *string) {
t.Helper()
var captured string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/agents" {
http.NotFound(w, r)
return
}
captured = r.URL.RawQuery
_, _ = io.WriteString(w, "[]")
}))
return srv, &captured
}

func TestRunAgentList_AddsTaskSquadScopeInAgentContext(t *testing.T) {
srv, captured := captureListAgentsQuery(t)
defer srv.Close()

t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_AGENT_ID", "agent-123")
t.Setenv("MULTICA_TASK_ID", "task-456")

cmd := freshAgentListCmd()
if err := runAgentList(cmd, nil); err != nil {
t.Fatalf("runAgentList: %v", err)
}

if !strings.Contains(*captured, "scope=task_squad") {
t.Fatalf("expected query to include scope=task_squad inside agent context, got %q", *captured)
}
}

func TestRunAgentList_OmitsTaskSquadScopeWithAllFlag(t *testing.T) {
srv, captured := captureListAgentsQuery(t)
defer srv.Close()

t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_AGENT_ID", "agent-123")
t.Setenv("MULTICA_TASK_ID", "task-456")

cmd := freshAgentListCmd()
if err := cmd.Flags().Set("all", "true"); err != nil {
t.Fatalf("set --all: %v", err)
}
if err := runAgentList(cmd, nil); err != nil {
t.Fatalf("runAgentList: %v", err)
}

if strings.Contains(*captured, "scope=task_squad") {
t.Fatalf("expected --all to suppress scope param, got %q", *captured)
}
}

func TestRunAgentList_OmitsTaskSquadScopeOutsideAgentContext(t *testing.T) {
srv, captured := captureListAgentsQuery(t)
defer srv.Close()

t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "")

cmd := freshAgentListCmd()
if err := runAgentList(cmd, nil); err != nil {
t.Fatalf("runAgentList: %v", err)
}

if strings.Contains(*captured, "scope=task_squad") {
t.Fatalf("expected no scope param outside agent context, got %q", *captured)
}
}
79 changes: 79 additions & 0 deletions server/internal/handler/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/pkg/agent"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
Expand Down Expand Up @@ -323,13 +324,34 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
// 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)

// Squad-leader scope. CLI passes ?scope=task_squad by default when
// running inside a daemon-managed agent task; --all opts out. The
// hint is only honored for agent actors that are actually running a
// leader task on a squad-assigned issue — otherwise the param is a
// no-op so worker tasks and one-off CLI calls keep their full A2A
// view. Pairs with the squad cross-squad mention gate in
// enqueueMentionedAgentTasks: list narrows what the leader sees,
// the gate hard-rejects what slips through anyway.
var squadScopeSet map[string]struct{}
if actorType == "agent" && r.URL.Query().Get("scope") == "task_squad" {
if set, ok := h.taskSquadMemberSet(r); ok {
squadScopeSet = set
}
}

visible := make([]AgentResponse, 0, len(agents))
for _, a := range agents {
if a.Visibility == "private" && actorType == "member" {
if !memberAllowedForPrivateAgent(a, actorID, member.Role) {
continue
}
}
if squadScopeSet != nil {
if _, inSquad := squadScopeSet[uuidToString(a.ID)]; !inSquad {
continue
}
}
resp := agentToResponse(a)
if skills, ok := skillMap[resp.ID]; ok {
resp.Skills = skills
Expand All @@ -345,6 +367,63 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, visible)
}

// taskSquadMemberSet returns the set of agent UUIDs (as strings) that belong
// to the squad of the task identified by the X-Task-ID header — the squad's
// LeaderID plus every squad_member of type "agent". The second return is
// false when the task is not a leader task on a squad-assigned issue, when
// the task isn't loadable, or when any of the squad lookups fail; callers
// treat the false return as "no scoping" and fall through to the unscoped
// list. The leader is always included even when the squad_member row was
// not auto-inserted (legacy squads predating CreateSquad's auto-add),
// matching the leader fallback in the enqueueMentionedAgentTasks gate.
func (h *Handler) taskSquadMemberSet(r *http.Request) (map[string]struct{}, bool) {
taskHeader := r.Header.Get("X-Task-ID")
if taskHeader == "" {
return nil, false
}
taskUUID, err := util.ParseUUID(taskHeader)
if err != nil {
return nil, false
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil {
return nil, false
}
if !task.IsLeaderTask {
return nil, false
}
if !task.IssueID.Valid {
return nil, false
}
issue, err := h.Queries.GetIssue(r.Context(), task.IssueID)
if err != nil {
return nil, false
}
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "squad" || !issue.AssigneeID.Valid {
return nil, false
}
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
ID: issue.AssigneeID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
return nil, false
}
members, err := h.Queries.ListSquadMembers(r.Context(), squad.ID)
if err != nil {
return nil, false
}
set := make(map[string]struct{}, len(members)+1)
set[uuidToString(squad.LeaderID)] = struct{}{}
for _, m := range members {
if m.MemberType != "agent" {
continue
}
set[uuidToString(m.MemberID)] = struct{}{}
}
return set, true
}

func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
Expand Down
74 changes: 74 additions & 0 deletions server/internal/handler/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,39 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
if shouldInheritParentMentions(parentComment, mentions, authorType) {
mentions = util.ParseMentions(parentComment.Content)
}

// Cross-squad @-mention gate: on a squad-assigned issue, an agent-authored
// comment may only enqueue tasks for agents that belong to the issue's
// squad. Catches the failure mode where a squad leader, using the A2A
// bypass on `multica agent list`, picks a same-role agent from a
// DIFFERENT squad / no squad at all instead of its own roster (see
// squadOperatingProtocol's "Squad Roster" rule). Member (human) authors
// keep full agency — they may deliberately reach outside the squad.
// Squad mentions (`m.Type == "squad"`) are not gated either; they route
// to the target squad's leader, who decides whether to accept.
var (
squadGate bool
squadGateSquad db.Squad
)
if authorType == "agent" && issue.AssigneeType.Valid && issue.AssigneeType.String == "squad" && issue.AssigneeID.Valid {
if s, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: issue.AssigneeID,
WorkspaceID: issue.WorkspaceID,
}); err == nil {
squadGate = true
squadGateSquad = s
} else {
// Squad assignee FK gone (delete race / inconsistent state):
// log and skip the gate. Failing open here matches the
// posture of the surrounding error paths in this file —
// observability is the recovery hook.
slog.Warn("cross-squad mention gate disabled: failed to load squad assignee",
"issue_id", uuidToString(issue.ID),
"squad_id", uuidToString(issue.AssigneeID),
"error", err)
}
}

for _, m := range mentions {
if m.Type == "squad" {
// @squad mention → trigger the squad's leader agent.
Expand Down Expand Up @@ -820,6 +853,47 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) {
continue
}
// Cross-squad gate: on a squad-assigned issue with an agent author,
// drop the @mention when the target agent is neither a squad_member
// row nor the squad's LeaderID. The leader fallback covers legacy
// squads whose creation path bypassed CreateSquad's auto squad_member
// insert — those rows still have a valid LeaderID and should be
// reachable.
if squadGate {
isLeader := uuidToString(squadGateSquad.LeaderID) == uuidToString(agentUUID)
isMember := false
gateActive := true
if !isLeader {
ok, memberErr := h.Queries.IsSquadMember(ctx, db.IsSquadMemberParams{
SquadID: squadGateSquad.ID,
MemberType: "agent",
MemberID: agentUUID,
})
if memberErr != nil {
// Same posture as the squad-load failure above: log and
// fail open for this mention so a transient DB error
// cannot wedge legitimate dispatch. The slog warn is
// the recovery hook.
slog.Warn("cross-squad mention gate: IsSquadMember query failed — allowing mention",
"issue_id", uuidToString(issue.ID),
"squad_id", uuidToString(squadGateSquad.ID),
"mentioned_agent_id", uuidToString(agentUUID),
"error", memberErr)
gateActive = false
}
isMember = ok
}
if gateActive && !isLeader && !isMember {
slog.Warn("cross-squad @mention dropped: target agent is not a member of the issue's squad",
"issue_id", uuidToString(issue.ID),
"squad_id", uuidToString(squadGateSquad.ID),
"squad_name", squadGateSquad.Name,
"mentioned_agent_id", uuidToString(agentUUID),
"mentioned_agent_name", agent.Name,
"author_agent_id", authorID)
continue
}
}
// Dedup: skip if this agent already has a pending task for this issue.
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
IssueID: issue.ID,
Expand Down
Loading
Loading