diff --git a/packages/views/squads/components/squad-detail-page.tsx b/packages/views/squads/components/squad-detail-page.tsx
index 5c74a655ae..92d5269542 100644
--- a/packages/views/squads/components/squad-detail-page.tsx
+++ b/packages/views/squads/components/squad-detail-page.tsx
@@ -103,19 +103,26 @@ export function SquadDetailPage() {
const { data: wsMembers = [] } = useQuery(memberListOptions(wsId));
// Runtimes are only fetched when the Create Agent dialog might open;
- // gating on isWorkspaceAdmin below means non-admins never trigger the
- // request. The runtime list mirrors the agents page so the picker
- // (and the "only my runtimes" filter) behaves identically here.
+ // gating on canManage below means users who can't manage this squad never
+ // trigger the request. The runtime list mirrors the agents page so the
+ // picker (and the "only my runtimes" filter) behaves identically here.
const currentUser = useAuthStore((s) => s.user);
const myRole = useMemo(() => {
if (!currentUser) return null;
return wsMembers.find((m) => m.user_id === currentUser.id)?.role ?? null;
}, [wsMembers, currentUser]);
const isWorkspaceAdmin = myRole === "owner" || myRole === "admin";
+ // Per-squad management gate: workspace owner/admin manage every squad; the
+ // creator manages the squads they created. Mirrors canManageSquad in
+ // server/internal/handler/squad.go so editable controls appear exactly when
+ // the API will accept the write, and everyone else gets a read-only view
+ // instead of controls that 403 (MUL-4223).
+ const canManage =
+ isWorkspaceAdmin || (!!currentUser && squad?.creator_id === currentUser.id);
const { data: runtimes = [], isLoading: runtimesLoading } = useQuery({
...runtimeListOptions(wsId),
- enabled: !!wsId && isWorkspaceAdmin,
+ enabled: !!wsId && canManage,
});
const [showAddMember, setShowAddMember] = useState(false);
@@ -233,10 +240,12 @@ export function SquadDetailPage() {
>
}
actions={
- setConfirmArchive(true)}>
-
- {t(($) => $.inspector.archive_button)}
-
+ canManage ? (
+ setConfirmArchive(true)}>
+
+ {t(($) => $.inspector.archive_button)}
+
+ ) : null
}
/>
@@ -249,6 +258,7 @@ export function SquadDetailPage() {
memberCount={members.length}
leaderName={getEntityName("agent", squad.leader_id)}
creatorName={getEntityName("member", squad.creator_id)}
+ canManage={canManage}
uploadingAvatar={updateSquadMut.isPending}
onUploadAvatar={(url) => updateSquadMut.mutateAsync({ avatar_url: url })}
onRename={async (next) => { await updateSquadMut.mutateAsync({ name: next.trim() }); }}
@@ -259,11 +269,12 @@ export function SquadDetailPage() {
squad={squad}
members={members}
memberStatusById={memberStatusById}
+ canManage={canManage}
isLeader={isLeader}
isArchived={isArchived}
getEntityName={getEntityName}
onAddMemberClick={() => setShowAddMember(true)}
- onCreateAgentClick={isWorkspaceAdmin ? () => setShowCreateAgent(true) : undefined}
+ onCreateAgentClick={canManage ? () => setShowCreateAgent(true) : undefined}
onSetLeader={(id) => setLeaderMut.mutate(id)}
onRemoveMember={(m) => removeMemberMut.mutate(m)}
onUpdateRole={async (m, role) => { await updateRoleMut.mutateAsync({ member: m, role }); }}
@@ -284,10 +295,11 @@ export function SquadDetailPage() {
{/* Squad-scoped create flow: same dialog as the Agents page but
with squadId set, so the dialog runs api.addSquadMember after
api.createAgent and skips the agent-detail navigation. Only
- mounted for workspace owner/admin since AddSquadMember is
- owner/admin-gated server-side; for everyone else the trigger
- never renders. */}
- {showCreateAgent && isWorkspaceAdmin && (
+ mounted for users who can manage this squad (workspace owner/admin
+ or the creator); for everyone else the trigger never renders. The
+ newly created agent is owned by the creator, so it is always one
+ they can invoke and add to the squad. */}
+ {showCreateAgent && canManage && (
+ {squad.avatar_url ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
// Inline name editor — reveals a Pencil affordance on hover, opens a small
// popover with a single-line input. Mirrors the NameAndDescription editor
// in the agent inspector.
@@ -802,6 +836,7 @@ function SquadDetailInspector({
memberCount,
leaderName,
creatorName,
+ canManage,
uploadingAvatar,
onUploadAvatar,
onRename,
@@ -811,6 +846,10 @@ function SquadDetailInspector({
memberCount: number;
leaderName: string;
creatorName: string;
+ // When false the identity block renders as static text (no avatar upload,
+ // no rename/description popovers) — the viewer can read the squad but not
+ // edit it. Mirrors the agent inspector's `canEdit` read-only treatment.
+ canManage: boolean;
uploadingAvatar: boolean;
onUploadAvatar: (url: string) => Promise;
onRename: (next: string) => Promise;
@@ -829,19 +868,39 @@ function SquadDetailInspector({
-
- {onCreateAgentClick && (
-
+ {canManage && (
+
+ {onCreateAgentClick && (
+
+
+ {t(($) => $.members_tab.create_agent_button)}
+
+ )}
+
- {t(($) => $.members_tab.create_agent_button)}
+ {t(($) => $.members_tab.add_member_button)}
- )}
-
-
- {t(($) => $.members_tab.add_member_button)}
-
-
+
+ )}
@@ -1238,10 +1310,14 @@ function SquadMembersTab({
)}
- { await onUpdateRole(m, next); }}
- />
+ {canManage ? (
+ { await onUpdateRole(m, next); }}
+ />
+ ) : m.role ? (
+ {m.role}
+ ) : null}
{primaryIssue && (
)}
- {m.member_type === "agent" && !isLeader(m) && !isArchived(m) && (
+ {canManage && m.member_type === "agent" && !isLeader(m) && !isArchived(m) && (
)}
- {!isLeader(m) && (
+ {canManage && !isLeader(m) && (
Promise;
onDirtyChange?: (dirty: boolean) => void;
}) {
@@ -1362,8 +1440,9 @@ function SquadInstructionsTab({
}, [squad.id, squad.instructions]);
useEffect(() => {
- onDirtyChange?.(isDirty);
- }, [isDirty, onDirtyChange]);
+ // A read-only viewer never has unsaved changes to guard on tab-switch.
+ onDirtyChange?.(canManage && isDirty);
+ }, [canManage, isDirty, onDirtyChange]);
const handleSave = async () => {
setSaving(true);
@@ -1382,31 +1461,46 @@ function SquadInstructionsTab({
{t(($) => $.instructions_tab.description)}
-
+ {/* When the viewer can't manage the squad, the editor is wrapped in a
+ pointer-events-none / aria-disabled shell — ContentEditor reads
+ `editable` at mount and can't be toggled, so this is the documented
+ way to present it read-only (see editor/content-editor.tsx). */}
+
{}}
+ placeholder={
+ canManage
+ ? "e.g. Always start by writing a failing test. Prefer small, atomic commits."
+ : ""
+ }
debounceMs={150}
disableMentions
className="min-h-full"
/>
-
- {isDirty && (
-
{t(($) => $.instructions_tab.unsaved_changes)}
- )}
-
- {saving ? (
-
- ) : (
-
+ {canManage && (
+
+ {isDirty && (
+ {t(($) => $.instructions_tab.unsaved_changes)}
)}
- {t(($) => $.instructions_tab.save_button)}
-
-
+
+ {saving ? (
+
+ ) : (
+
+ )}
+ {t(($) => $.instructions_tab.save_button)}
+
+
+ )}
);
}
diff --git a/packages/views/squads/components/squads-page.tsx b/packages/views/squads/components/squads-page.tsx
index d02f199aed..16d38f353e 100644
--- a/packages/views/squads/components/squads-page.tsx
+++ b/packages/views/squads/components/squads-page.tsx
@@ -874,6 +874,16 @@ export function SquadsPage() {
return sorted;
}, [scopeRows, filters, sortField, sortDirection]);
+ // Reserve the row-actions (kebab) track when the current user can manage at
+ // least one visible squad. Workspace admins manage all squads; a regular
+ // member manages the squads they created (MUL-4223).
+ const canManageAnyRow = useMemo(
+ () =>
+ isWorkspaceAdmin ||
+ (!!currentUser && rows.some((s) => s.creator_id === currentUser.id)),
+ [isWorkspaceAdmin, rows, currentUser],
+ );
+
return (
@@ -940,7 +950,7 @@ export function SquadsPage() {
@@ -994,7 +1004,8 @@ export function SquadsPage() {
)}
- {isWorkspaceAdmin ? (
+ {isWorkspaceAdmin ||
+ (!!currentUser && squad.creator_id === currentUser.id) ? (
) : null}
diff --git a/server/internal/handler/squad.go b/server/internal/handler/squad.go
index 83d9538313..94c8ffd155 100644
--- a/server/internal/handler/squad.go
+++ b/server/internal/handler/squad.go
@@ -109,6 +109,36 @@ func applySquadMemberSummary(resp *SquadResponse, summary *squadMemberSummary) {
// ── Helpers ─────────────────────────────────────────────────────────────────
+// canManageSquad reports whether the member may mutate the squad. Workspace
+// owner/admin manage every squad; a regular member manages only the squads
+// they created. Squads stay creator-scoped for management while remaining
+// visible workspace-wide (ListSquads is unfiltered). Mirrors the front-end
+// per-squad `canManage` gate so the UI and API agree on who can rename / add
+// members / archive (MUL-4223).
+func canManageSquad(member db.Member, squad db.Squad) bool {
+ if roleAllowed(member.Role, "owner", "admin") {
+ return true
+ }
+ return uuidToString(squad.CreatorID) == uuidToString(member.UserID)
+}
+
+// memberCanWireAgent reports whether the acting member may attach the given
+// agent to a squad (as leader or worker). Workspace owner/admin may wire any
+// workspace agent — their management surface is unchanged. A regular member
+// (a creator managing their own squad) may only wire agents they can
+// @-trigger: canInvokeAgent judged as the member themselves, so public_to
+// agents on their allow-list and their own private agents pass, while other
+// members' private / non-allow-listed agents are rejected. This stops a
+// creator from smuggling an agent they cannot invoke into a squad and reaching
+// it through squad routing (MUL-4223).
+func (h *Handler) memberCanWireAgent(ctx context.Context, member db.Member, agent db.Agent, workspaceID string) bool {
+ if roleAllowed(member.Role, "owner", "admin") {
+ return true
+ }
+ uid := uuidToString(member.UserID)
+ return h.canInvokeAgent(ctx, agent, "member", uid, uid, workspaceID)
+}
+
// loadSquadInWorkspace loads a squad scoped to the current workspace.
func (h *Handler) loadSquadInWorkspace(w http.ResponseWriter, r *http.Request) (db.Squad, string, bool) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
@@ -194,7 +224,10 @@ func (h *Handler) ListSquads(w http.ResponseWriter, r *http.Request) {
func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
- member, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
+ // Any workspace member can create a squad and becomes its creator
+ // (CreatorID below). This aligns squads with agents/projects, which are
+ // also member-creatable; management stays creator-scoped (MUL-4223).
+ member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
@@ -228,7 +261,7 @@ func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) {
}
// Validate leader is an agent in this workspace.
- _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
+ leaderAgent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: leaderUUID,
WorkspaceID: wsUUID,
})
@@ -236,6 +269,12 @@ func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace")
return
}
+ // A non-admin creator may only lead their squad with an agent they can
+ // @-trigger; admins may wire any workspace agent (MUL-4223).
+ if !h.memberCanWireAgent(r.Context(), member, leaderAgent, workspaceID) {
+ writeError(w, http.StatusForbidden, "you can only use an agent you have access to as leader")
+ return
+ }
avatarURL := pgtype.Text{}
if req.AvatarURL != nil {
@@ -293,7 +332,8 @@ func (h *Handler) GetSquad(w http.ResponseWriter, r *http.Request) {
func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
- if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
+ member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
+ if !ok {
return
}
@@ -301,6 +341,10 @@ func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ if !canManageSquad(member, squad) {
+ writeError(w, http.StatusForbidden, "insufficient permissions")
+ return
+ }
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
@@ -337,12 +381,18 @@ func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
return
}
// Validate new leader is an agent in workspace.
- if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
+ newLeader, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: lid, WorkspaceID: wsUUID,
- }); err != nil {
+ })
+ if err != nil {
writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace")
return
}
+ // A non-admin creator may only promote an agent they can @-trigger.
+ if !h.memberCanWireAgent(r.Context(), member, newLeader, workspaceID) {
+ writeError(w, http.StatusForbidden, "you can only use an agent you have access to as leader")
+ return
+ }
// Ensure new leader is a squad member; auto-add if not.
isMember, _ := h.Queries.IsSquadMember(r.Context(), db.IsSquadMemberParams{
SquadID: squad.ID, MemberType: "agent", MemberID: lid,
@@ -372,7 +422,8 @@ func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
- if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
+ member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
+ if !ok {
return
}
@@ -380,6 +431,10 @@ func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ if !canManageSquad(member, squad) {
+ writeError(w, http.StatusForbidden, "insufficient permissions")
+ return
+ }
if squad.ArchivedAt.Valid {
writeError(w, http.StatusBadRequest, "squad is already archived")
@@ -634,7 +689,8 @@ func (h *Handler) ListSquadMemberStatus(w http.ResponseWriter, r *http.Request)
func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
- if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
+ member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
+ if !ok {
return
}
@@ -642,6 +698,10 @@ func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ if !canManageSquad(member, squad) {
+ writeError(w, http.StatusForbidden, "insufficient permissions")
+ return
+ }
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
@@ -672,12 +732,20 @@ func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
// Validate the member belongs to this workspace.
if req.MemberType == "agent" {
- if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
+ agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: memberUUID, WorkspaceID: wsUUID,
- }); err != nil {
+ })
+ if err != nil {
writeError(w, http.StatusBadRequest, "agent not found in this workspace")
return
}
+ // A non-admin creator may only add agents they can @-trigger (public
+ // or their own / allow-listed agents); admins may add any workspace
+ // agent (MUL-4223).
+ if !h.memberCanWireAgent(r.Context(), member, agent, workspaceID) {
+ writeError(w, http.StatusForbidden, "you can only add an agent you have access to")
+ return
+ }
} else {
if _, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: memberUUID, WorkspaceID: wsUUID,
@@ -710,7 +778,8 @@ func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
func (h *Handler) RemoveSquadMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
- if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
+ member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
+ if !ok {
return
}
@@ -718,6 +787,10 @@ func (h *Handler) RemoveSquadMember(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ if !canManageSquad(member, squad) {
+ writeError(w, http.StatusForbidden, "insufficient permissions")
+ return
+ }
var req struct {
MemberType string `json:"member_type"`
@@ -761,7 +834,8 @@ func (h *Handler) RemoveSquadMember(w http.ResponseWriter, r *http.Request) {
func (h *Handler) UpdateSquadMemberRole(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
- if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
+ member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
+ if !ok {
return
}
@@ -769,6 +843,10 @@ func (h *Handler) UpdateSquadMemberRole(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ if !canManageSquad(member, squad) {
+ writeError(w, http.StatusForbidden, "insufficient permissions")
+ return
+ }
var req struct {
MemberType string `json:"member_type"`
diff --git a/server/internal/handler/squad_creator_scope_test.go b/server/internal/handler/squad_creator_scope_test.go
new file mode 100644
index 0000000000..7049fcf21a
--- /dev/null
+++ b/server/internal/handler/squad_creator_scope_test.go
@@ -0,0 +1,222 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// squadScopeReq builds a request as the given user (empty = workspace owner)
+// with the chi URL params the squad handlers read (workspaceId + optional id).
+// The squad handlers resolve the workspace from workspaceIDFromURL, which reads
+// the chi route context, not the query string — so tests must inject the params
+// here rather than on the path.
+func squadScopeReq(userID, method, path string, body any, params map[string]string) *http.Request {
+ var req *http.Request
+ if userID == "" {
+ req = newRequest(method, path, body)
+ } else {
+ req = newRequestAs(userID, method, path, body)
+ }
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("workspaceId", testWorkspaceID)
+ for k, v := range params {
+ rctx.URLParams.Add(k, v)
+ }
+ return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+}
+
+// createSquadAs creates a squad through the handler as the given user and
+// returns the decoded response. Registers cleanup for the squad + its members.
+func createSquadAs(t *testing.T, userID, name, leaderID string) SquadResponse {
+ t.Helper()
+ w := httptest.NewRecorder()
+ r := squadScopeReq(userID, "POST", "/api/squads", map[string]any{
+ "name": name,
+ "leader_id": leaderID,
+ }, nil)
+ testHandler.CreateSquad(w, r)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateSquad(%s): expected 201, got %d: %s", name, w.Code, w.Body.String())
+ }
+ var resp SquadResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode squad: %v", err)
+ }
+ t.Cleanup(func() {
+ testPool.Exec(context.Background(), `DELETE FROM squad_member WHERE squad_id = $1`, resp.ID)
+ testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, resp.ID)
+ })
+ return resp
+}
+
+// TestCreateSquad_PlainMemberBecomesCreator verifies the gate change: a plain
+// workspace member (not owner/admin) can create a squad and is recorded as its
+// creator.
+func TestCreateSquad_PlainMemberBecomesCreator(t *testing.T) {
+ if testHandler == nil || testPool == nil {
+ t.Skip("database not available")
+ }
+ memberID := createPlainMember(t, "squad-creator@multica.test")
+ leaderID := createHandlerTestAgent(t, "squad-creator-leader", nil)
+
+ squad := createSquadAs(t, memberID, "Member Owned Squad", leaderID)
+ if squad.CreatorID != memberID {
+ t.Fatalf("expected creator_id=%s, got %s", memberID, squad.CreatorID)
+ }
+}
+
+// TestManageSquad_CreatorCanManageOwn verifies a creator can update, add a
+// member to, and archive their own squad.
+func TestManageSquad_CreatorCanManageOwn(t *testing.T) {
+ if testHandler == nil || testPool == nil {
+ t.Skip("database not available")
+ }
+ memberID := createPlainMember(t, "squad-owner-manage@multica.test")
+ leaderID := createHandlerTestAgent(t, "squad-owner-manage-leader", nil)
+ worker := createHandlerTestAgent(t, "squad-owner-manage-worker", nil)
+
+ squad := createSquadAs(t, memberID, "Manage Own Squad", leaderID)
+
+ // Update name.
+ w := httptest.NewRecorder()
+ testHandler.UpdateSquad(w, squadScopeReq(memberID, "PATCH", "/api/squads", map[string]any{
+ "name": "Renamed By Creator",
+ }, map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusOK {
+ t.Fatalf("UpdateSquad as creator: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Add a public agent worker.
+ w = httptest.NewRecorder()
+ testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
+ "member_type": "agent",
+ "member_id": worker,
+ }, map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusCreated {
+ t.Fatalf("AddSquadMember as creator: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Archive.
+ w = httptest.NewRecorder()
+ testHandler.DeleteSquad(w, squadScopeReq(memberID, "DELETE", "/api/squads", nil,
+ map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("DeleteSquad as creator: expected 204, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+// TestManageSquad_StrangerMemberForbidden verifies a plain member who did not
+// create the squad cannot manage it, while a workspace admin/owner still can.
+func TestManageSquad_StrangerMemberForbidden(t *testing.T) {
+ if testHandler == nil || testPool == nil {
+ t.Skip("database not available")
+ }
+ creatorID := createPlainMember(t, "squad-stranger-creator@multica.test")
+ strangerID := createPlainMember(t, "squad-stranger-other@multica.test")
+ leaderID := createHandlerTestAgent(t, "squad-stranger-leader", nil)
+
+ squad := createSquadAs(t, creatorID, "Stranger Test Squad", leaderID)
+
+ // Stranger member: update denied.
+ w := httptest.NewRecorder()
+ testHandler.UpdateSquad(w, squadScopeReq(strangerID, "PATCH", "/api/squads", map[string]any{
+ "name": "Hijacked",
+ }, map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("UpdateSquad as stranger: expected 403, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Stranger member: archive denied.
+ w = httptest.NewRecorder()
+ testHandler.DeleteSquad(w, squadScopeReq(strangerID, "DELETE", "/api/squads", nil,
+ map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("DeleteSquad as stranger: expected 403, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Workspace owner (testUserID): update allowed — admin management unchanged.
+ w = httptest.NewRecorder()
+ testHandler.UpdateSquad(w, squadScopeReq("", "PATCH", "/api/squads", map[string]any{
+ "name": "Renamed By Admin",
+ }, map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusOK {
+ t.Fatalf("UpdateSquad as workspace owner: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+// TestAddSquadMember_CreatorAgentAccessGate verifies the comment-#2 rule: a
+// non-admin creator may add a public agent (invocable) but not a private agent
+// they cannot @-trigger. The workspace owner may add the same private agent —
+// admin wiring is unrestricted.
+func TestAddSquadMember_CreatorAgentAccessGate(t *testing.T) {
+ if testHandler == nil || testPool == nil {
+ t.Skip("database not available")
+ }
+ privateAgentID, _, memberID := privateAgentTestFixture(t)
+ publicLeaderID := createHandlerTestAgent(t, "squad-gate-leader", nil)
+ publicWorkerID := createHandlerTestAgent(t, "squad-gate-worker", nil)
+
+ squad := createSquadAs(t, memberID, "Agent Gate Squad", publicLeaderID)
+
+ // Creator adds a public (invocable) worker — allowed.
+ w := httptest.NewRecorder()
+ testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
+ "member_type": "agent",
+ "member_id": publicWorkerID,
+ }, map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusCreated {
+ t.Fatalf("AddSquadMember public agent: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Creator adds a private agent they cannot invoke — denied.
+ w = httptest.NewRecorder()
+ testHandler.AddSquadMember(w, squadScopeReq(memberID, "POST", "/api/squads/members", map[string]any{
+ "member_type": "agent",
+ "member_id": privateAgentID,
+ }, map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("AddSquadMember private agent as creator: expected 403, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Workspace owner adds the same private agent — allowed (admin unchanged).
+ w = httptest.NewRecorder()
+ testHandler.AddSquadMember(w, squadScopeReq("", "POST", "/api/squads/members", map[string]any{
+ "member_type": "agent",
+ "member_id": privateAgentID,
+ }, map[string]string{"id": squad.ID}))
+ if w.Code != http.StatusCreated {
+ t.Fatalf("AddSquadMember private agent as owner: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+// TestCreateSquad_CreatorPrivateLeaderForbidden verifies a non-admin cannot
+// create a squad led by a private agent they cannot @-trigger.
+func TestCreateSquad_CreatorPrivateLeaderForbidden(t *testing.T) {
+ if testHandler == nil || testPool == nil {
+ t.Skip("database not available")
+ }
+ privateAgentID, _, memberID := privateAgentTestFixture(t)
+
+ w := httptest.NewRecorder()
+ r := squadScopeReq(memberID, "POST", "/api/squads", map[string]any{
+ "name": "Private Leader Squad",
+ "leader_id": privateAgentID,
+ }, nil)
+ testHandler.CreateSquad(w, r)
+ if w.Code != http.StatusForbidden {
+ // Nothing should have been created; if it slipped through, clean up.
+ if w.Code == http.StatusCreated {
+ var resp SquadResponse
+ if json.NewDecoder(w.Body).Decode(&resp) == nil {
+ testPool.Exec(context.Background(), `DELETE FROM squad_member WHERE squad_id = $1`, resp.ID)
+ testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, resp.ID)
+ }
+ }
+ t.Fatalf("CreateSquad with private leader: expected 403, got %d: %s", w.Code, w.Body.String())
+ }
+}