mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* fix(comment): restore autopilot @mention delegation authority (MUL-4857) A schedule/webhook autopilot run is unattributed by design (no top-of-chain human originator, MUL-4302). Since MUL-3963 the A2A invoke gate (canInvokeAgent) keys on that originator, so a mid-run @agent/@squad delegation on an autopilot-created issue fails closed for the DEFAULT private agent (and member-scoped public_to agents): the mention renders but no run is enqueued. The SAME autopilot's first dispatch is admitted via the autopilot creator (autopilotAdmitInvoke -> canCreatorInvokeAgent), so first-dispatch and mid-run delegation disagreed. Align them: when an unattributed agent/system-authored comment on an autopilot-origin issue reaches computeCommentAgentTriggers with no originator, fall back to the autopilot creator as the effective invoking user for the gate. The gate still runs (no unrestricted agent-to-agent bypass); it is authorization only -- the enqueued task's originator/attribution stays unattributed. Scoped to autopilot-origin issues so other unattributed chains stay fail-closed. Adds a DB-backed regression test covering: creator-owns-target admits, a non-autopilot unattributed run stays denied, and a creator without invoke rights stays denied. Co-authored-by: multica-agent <github@multica.ai> * fix(comment): bind autopilot @mention authority to verified task lineage (MUL-4857) Address the review's confused-deputy finding on the P0 fix. The first cut keyed the invoke-gate fallback on issue provenance + an empty originator alone (invokeAuthorityForAutopilotIssue took only the issue), so any unattributed run could borrow a stranger autopilot creator's rights merely by commenting on that autopilot's issue — and the fallback also leaked past explicit @mention into the plain-comment squad-leader path and system actors. Rework it so the autopilot-creator authority is granted ONLY when the SPEAKING task's lineage is verified against this issue: - resolve the authority separately (new AutopilotDelegationAuthorityUserID on commentTriggerComputeOptions), never by overwriting OriginatorUserID; the gate reads it through opts.effectiveInvoker() only when no human originator resolved, so attribution stays untouched; - resolve from a server-trusted speaking task — X-Task-ID on create/preview, comment.source_task_id on edit/reconcile — via autopilotDelegationAuthority, which admits only when author == task agent AND task.issue_id == this issue AND the issue is autopilot-origin, then keys on the member autopilot creator; - do NOT key on autopilot_run_id: in create_issue mode (the reported case) the leader task is enqueued through the ordinary issue-assignment path and has no autopilot_run_id — the task.issue_id == issue binding is what proves the run is part of this autopilot's work while rejecting foreign-issue runs. Tests: replace the provenance-only regression with lineage-bound coverage — verified-lineage-admits, creator-without-rights-denied, non-autopilot-denied, missing-source-task-denied, cross-issue-source-task-denied, author!=task-agent- denied — plus an end-to-end CreateComment path asserting the private worker is enqueued and the delegated run stays unattributed. Verified the fallback is load-bearing (positive + e2e fail with it disabled) and the full internal/handler package passes. Skill docs (multica-mentioning) updated to the lineage-bound contract and new helper names. Co-authored-by: multica-agent <github@multica.ai> * fix(comment): make autopilot @mention authority consistent across defer/edit (MUL-4857) Second review round (Elon) surfaced two must-fixes on top of the lineage binding. 1. Busy-target completion reconcile lost the authority. A delegation to a target that is already running is deferred to that target's completion reconcile (reconcileCommentsOnCompletion). That path recomputed triggers with only the (empty) originator, so an unattributed autopilot delegation's follow-up was gate-denied again and silently dropped. It now restores the delegation authority from comment.source_task_id, so the follow-up fires once the target frees up — still unattributed. 2. Edit could borrow the old authoring run's authority, and preview != save. The edit preview keyed authority on the current request task while save keyed it on the comment's original source_task_id, so an agent editing its old autopilot comment from a task on an UNRELATED issue would fail-closed in preview but reuse the old autopilot creator's authority on save (cross-issue confused-deputy, and a preview/side-effect divergence). Fix: treat source_task_id as the persisted per-action authority lineage and re-stamp it on edit to the CURRENT editing task, issue-scoped exactly like CreateComment. A cross-issue edit re-stamps it to NULL, so preview, save, AND the deferred reconcile all fail closed identically. UpdateComment query gains a source_task_id param (sqlc regen). Also locks the review-accepted behavior that effectiveInvoker() carries the autopilot-creator authority into the plain assigned-squad-leader wake (a worker's result comment on the autopilot issue can still wake the private leader). Tests: reconcile-restores-authority (owns -> one unattributed follow-up; no rights -> none); edit re-stamp (same-issue keeps authority and triggers; cross-issue clears source_task_id and fails closed); worker-result wakes private squad leader. Verified both fixes are load-bearing (each negative control reproduces the exact regression Elon described), full internal/handler + internal/service packages pass, gofmt/vet clean. Skill docs (multica-mentioning) updated. Co-authored-by: multica-agent <github@multica.ai> * fix(comment): clear stale task lineage on non-author comment edits (MUL-4857) An admin editing an autopilot Agent's comment previously preserved the comment's original source_task_id. The immediate save is judged on the admin's member identity and correctly fails closed, but the deferred completion-reconcile routes the comment under its original agent author and resolved the delegation authority from the stale source_task_id, resurrecting the autopilot creator's invoke authority once the busy target freed up — an admin (manage rights) could thereby trigger another owner's private agent (invoke rights). Now a content edit re-derives lineage from the edit action: only the agent author editing its own comment re-stamps source_task_id to the current editing task; every other editor (member/admin, or any non-author) clears it, so preview, save, and reconcile all fail closed. Adds a regression covering the admin-edit + busy-target path and syncs the multica-mentioning skill docs. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
401 lines
17 KiB
Go
401 lines
17 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Agent invocation permission model (MUL-3963).
|
|
//
|
|
// Two distinct questions, previously conflated in canAccessPrivateAgent:
|
|
//
|
|
// - "can this actor SEE / open this agent in the UI" -> canAccessPrivateAgent
|
|
// - "can this actor TRIGGER a run for this agent" -> canInvokeAgent
|
|
//
|
|
// The invoke gate is the security-critical one: a workspace admin must NOT be
|
|
// able to invoke someone's private agent (and thereby use that owner's
|
|
// Composio/OAuth connections) just because they are an admin. Admin retains
|
|
// management + inventory visibility, not the ability to run.
|
|
//
|
|
// permission_mode drives invoke:
|
|
// - private -> only the agent owner may invoke; NO admin bypass, NO A2A bypass.
|
|
// - public_to -> the agent_invocation_target allow-list decides:
|
|
// * workspace target -> any workspace member (and workspace-internal
|
|
// agent/system principals) may invoke.
|
|
// * member target -> only the specific user may invoke.
|
|
// * team target -> reserved, inert in V1.
|
|
//
|
|
// A2A is judged by the top-of-chain human originator, never by the immediate
|
|
// agent actor: if user U triggers agent A and A @-mentions agent B, B is only
|
|
// invocable when U (the originator) is in B's allow-list. This prevents agents
|
|
// from forming a channel that bypasses the owner's white-list.
|
|
|
|
// canInvokeAgent reports whether a run may be enqueued for `agent` on behalf of
|
|
// the given actor. Judgement is by the *effective invoking user*:
|
|
// - member actor -> the member themselves (actorID)
|
|
// - agent actor -> the top-of-chain human originator (originatorUserID)
|
|
// - system actor -> the originator when one was resolved, else no user
|
|
//
|
|
// originatorUserID is the empty string when no human could be attributed. For
|
|
// private agents that means "deny" (unless the actor is the owner). For
|
|
// public_to agents, a workspace target still admits workspace-internal
|
|
// agent/system principals, but member/team targets fail closed without a
|
|
// matching human.
|
|
func (h *Handler) canInvokeAgent(ctx context.Context, agent db.Agent, actorType, actorID, originatorUserID, workspaceID string) bool {
|
|
effectiveUser := actorID
|
|
if actorType != "member" {
|
|
// agent / system: never trust the immediate principal, only the
|
|
// resolved human originator at the top of the chain.
|
|
effectiveUser = originatorUserID
|
|
}
|
|
|
|
// The agent owner may always invoke their own agent.
|
|
if effectiveUser != "" && uuidToString(agent.OwnerID) == effectiveUser {
|
|
return true
|
|
}
|
|
|
|
if agent.PermissionMode != "public_to" {
|
|
// private (or any unknown mode) is deny-by-default: no admin bypass,
|
|
// no A2A bypass. Only the owner branch above passes.
|
|
return false
|
|
}
|
|
|
|
targets, err := h.Queries.ListAgentInvocationTargets(ctx, agent.ID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Agents and system triggers are workspace-internal principals: a
|
|
// workspace target admits them even when no human originator resolved.
|
|
// This is a DELIBERATE, product-approved exception (MUL-3963): webhook /
|
|
// system / workspace-wide automation must be able to trigger a
|
|
// `public_to workspace` agent even though there is no human at the top of
|
|
// the chain. It is scoped tightly — it ONLY relaxes the *workspace* target.
|
|
// member/team targets still require a resolved human originator to match,
|
|
// so an unattributed agent/system trigger FAILS CLOSED against a
|
|
// member-/team-scoped private-ish allow-list and can never smuggle itself
|
|
// onto someone's specific-people grant.
|
|
workspaceBroad := actorType == "agent" || actorType == "system"
|
|
isWorkspaceMember := false
|
|
if effectiveUser != "" {
|
|
if _, err := h.getWorkspaceMember(ctx, effectiveUser, workspaceID); err == nil {
|
|
isWorkspaceMember = true
|
|
}
|
|
}
|
|
|
|
for _, t := range targets {
|
|
switch t.TargetType {
|
|
case "workspace":
|
|
if isWorkspaceMember || workspaceBroad {
|
|
return true
|
|
}
|
|
case "member":
|
|
// Requires a resolved human. agent/system triggers with no
|
|
// originator (effectiveUser == "") never match here — fail closed.
|
|
if effectiveUser != "" && uuidToString(t.TargetID) == effectiveUser {
|
|
return true
|
|
}
|
|
case "team":
|
|
// Reserved: team membership does not exist yet in V1, so team
|
|
// targets never admit anyone (also fail-closed for system/agent).
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// canAccessPrivateAgent gates the VIEW surfaces (list/detail navigation, chat
|
|
// transcript read, task-cancel authorization). It is NOT the trigger gate —
|
|
// see canInvokeAgent for that.
|
|
//
|
|
// Rules:
|
|
// - agent actors always pass (A2A collaboration + inspection preserved).
|
|
// - the agent owner always passes.
|
|
// - workspace owner/admin pass (governance / inventory visibility retained).
|
|
// - a regular member passes for a public_to agent only when they hit a
|
|
// workspace or member target; private agents stay owner+admin only.
|
|
func (h *Handler) canAccessPrivateAgent(ctx context.Context, agent db.Agent, actorType, actorID, workspaceID string) bool {
|
|
if actorType == "agent" {
|
|
return true
|
|
}
|
|
if uuidToString(agent.OwnerID) == actorID {
|
|
return true
|
|
}
|
|
member, err := h.getWorkspaceMember(ctx, actorID, workspaceID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if roleAllowed(member.Role, "owner", "admin") {
|
|
return true
|
|
}
|
|
if agent.PermissionMode != "public_to" {
|
|
return false
|
|
}
|
|
targets, err := h.Queries.ListAgentInvocationTargets(ctx, agent.ID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return memberHitsInvocationTargets(targets, actorID)
|
|
}
|
|
|
|
// memberHitsInvocationTargets is the pure predicate deciding whether a regular
|
|
// member is on a public_to agent's allow-list, used by both the single-agent
|
|
// view gate and the ListAgents batch filter. A workspace target admits any
|
|
// member; a member target admits the matching user; team targets are inert.
|
|
func memberHitsInvocationTargets(targets []db.AgentInvocationTarget, userID string) bool {
|
|
for _, t := range targets {
|
|
switch t.TargetType {
|
|
case "workspace":
|
|
return true
|
|
case "member":
|
|
if uuidToString(t.TargetID) == userID {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// memberAllowedToViewAgent is the ListAgents / aggregation filter predicate.
|
|
// Caller supplies the agent's already-batch-loaded invocation targets so the
|
|
// list endpoint avoids an N+1. Workspace owner/admin and the agent owner see
|
|
// everything; a regular member sees a public_to agent only when on its
|
|
// allow-list, and never sees other members' private agents.
|
|
func memberAllowedToViewAgent(agent db.Agent, targets []db.AgentInvocationTarget, userID, role string) bool {
|
|
if roleAllowed(role, "owner", "admin") {
|
|
return true
|
|
}
|
|
if uuidToString(agent.OwnerID) == userID {
|
|
return true
|
|
}
|
|
if agent.PermissionMode != "public_to" {
|
|
return false
|
|
}
|
|
return memberHitsInvocationTargets(targets, userID)
|
|
}
|
|
|
|
// invokeOriginatorFromRequest resolves the top-of-chain human user id for an
|
|
// invocation initiated over HTTP. Members are their own originator; agent
|
|
// actors inherit the originator from the task named by the X-Task-ID header
|
|
// (set by the CLI on every request), matching
|
|
// TaskService.resolveOriginatorFromTriggerComment. Returns "" when no human
|
|
// can be attributed — canInvokeAgent then fails closed for member/team targets.
|
|
func (h *Handler) invokeOriginatorFromRequest(r *http.Request, actorType, actorID string) string {
|
|
if actorType == "member" {
|
|
return actorID
|
|
}
|
|
if actorType == "agent" {
|
|
if taskIDHeader := r.Header.Get("X-Task-ID"); taskIDHeader != "" {
|
|
if taskUUID, err := util.ParseUUID(taskIDHeader); err == nil {
|
|
if task, err := h.Queries.GetAgentTask(r.Context(), taskUUID); err == nil {
|
|
return uuidToString(task.OriginatorUserID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// autopilotDelegationAuthority resolves the effective invoking human for the A2A
|
|
// invoke gate (canInvokeAgent) when a trigger comment is authored by an
|
|
// UNATTRIBUTED autopilot dispatch delegating mid-chain on the very issue that
|
|
// autopilot created (MUL-4857).
|
|
//
|
|
// A schedule/webhook autopilot run carries no top-of-chain human originator by
|
|
// design (MUL-4302). Without one, canInvokeAgent fails closed for the DEFAULT
|
|
// private agent (and member-scoped public_to agents), so a mid-run @mention
|
|
// delegation silently enqueues nothing — even though the SAME autopilot's first
|
|
// dispatch was admitted via the autopilot creator (autopilotAdmitInvoke ->
|
|
// canCreatorInvokeAgent). This restores exactly that first-dispatch authority for
|
|
// the mid-run delegation path: the gate still runs, now keyed on the autopilot
|
|
// creator, so NO unrestricted agent-to-agent bypass is reopened.
|
|
//
|
|
// SECURITY (confused-deputy defense, review MUL-4857): the creator's authority is
|
|
// granted ONLY when the SPEAKING run is verified to be doing work on THIS very
|
|
// autopilot-created issue. Binding to issue provenance + an empty originator alone
|
|
// is NOT enough — an agent running a task on some OTHER issue can legitimately
|
|
// comment here (comment.go CreateComment only stamps source_task_id when the
|
|
// authoring task's issue matches), so it could otherwise borrow a stranger
|
|
// autopilot creator's invoke rights just by mentioning on that autopilot's issue.
|
|
// `task` MUST therefore come from a server-trusted source — the X-Task-ID header
|
|
// on create/preview, or the stored comment.source_task_id on reconcile/edit —
|
|
// never a client-supplied field, and authority is granted only when ALL hold:
|
|
// - the comment author is an agent and IS the task's agent;
|
|
// - the issue is autopilot-origin (origin_type=autopilot, origin_id set);
|
|
// - the speaking task is running on THIS issue (task.issue_id == issue.id).
|
|
//
|
|
// That last check is the load-bearing one: every unattributed agent task whose
|
|
// issue_id is this autopilot issue is part of the work this autopilot set in
|
|
// motion (the dispatched leader task, or a descendant it @mentioned into being),
|
|
// while a foreign run's task carries a different issue_id and is rejected. Note we
|
|
// do NOT key on autopilot_run_id: in create_issue mode (the reported scenario) the
|
|
// leader task is enqueued through the ordinary issue-assignment path and carries
|
|
// no autopilot_run_id — the run links back via its own issue_id, not the task's.
|
|
//
|
|
// Any mismatch, missing lineage, or lookup error returns "" and the gate stays
|
|
// fail-closed. Only a MEMBER-created autopilot yields a user id; an agent-created
|
|
// autopilot has no human to key the gate on, and the existing agent-actor
|
|
// workspace-target exception in canInvokeAgent already covers the one case
|
|
// (public_to workspace) it should. The returned id is used for AUTHORIZATION only
|
|
// — the enqueued task's originator/attribution is computed separately and stays
|
|
// unattributed.
|
|
func (h *Handler) autopilotDelegationAuthority(ctx context.Context, issue db.Issue, authorType, authorID string, task db.AgentTaskQueue) string {
|
|
if authorType != "agent" {
|
|
return ""
|
|
}
|
|
if !issue.OriginType.Valid || issue.OriginType.String != "autopilot" || !issue.OriginID.Valid {
|
|
return ""
|
|
}
|
|
// The speaking run must be authored by THIS agent and doing work on THIS
|
|
// autopilot issue — not a foreign run that merely commented here.
|
|
if !task.AgentID.Valid || uuidToString(task.AgentID) != authorID {
|
|
return ""
|
|
}
|
|
if !task.IssueID.Valid || uuidToString(task.IssueID) != uuidToString(issue.ID) {
|
|
return ""
|
|
}
|
|
ap, err := h.Queries.GetAutopilotInWorkspace(ctx, db.GetAutopilotInWorkspaceParams{
|
|
ID: issue.OriginID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil || ap.CreatedByType != "member" || !ap.CreatedByID.Valid {
|
|
return ""
|
|
}
|
|
return uuidToString(ap.CreatedByID)
|
|
}
|
|
|
|
// autopilotDelegationAuthorityFromRequest resolves the MUL-4857 delegation
|
|
// authority for a comment being created or previewed over HTTP. The speaking task
|
|
// is taken from the server-trusted X-Task-ID header (the CLI stamps it on every
|
|
// agent request); autopilotDelegationAuthority then verifies its lineage. Returns
|
|
// "" for member actors or when no valid task is named, keeping the gate closed.
|
|
func (h *Handler) autopilotDelegationAuthorityFromRequest(r *http.Request, issue db.Issue, actorType, actorID string) string {
|
|
if actorType != "agent" {
|
|
return ""
|
|
}
|
|
task, ok := h.taskFromRequestHeader(r)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return h.autopilotDelegationAuthority(r.Context(), issue, actorType, actorID, task)
|
|
}
|
|
|
|
// autopilotDelegationAuthorityFromComment resolves the MUL-4857 delegation
|
|
// authority when reconciling an already-persisted comment (retrigger after
|
|
// cancel). The speaking task is taken from the stored comment.source_task_id — the
|
|
// same server-trusted lineage CreateComment stamped for the authoring run — and
|
|
// its lineage is verified by autopilotDelegationAuthority.
|
|
func (h *Handler) autopilotDelegationAuthorityFromComment(ctx context.Context, issue db.Issue, comment db.Comment) string {
|
|
if comment.AuthorType != "agent" || !comment.SourceTaskID.Valid {
|
|
return ""
|
|
}
|
|
task, err := h.Queries.GetAgentTask(ctx, comment.SourceTaskID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return h.autopilotDelegationAuthority(ctx, issue, comment.AuthorType, uuidToString(comment.AuthorID), task)
|
|
}
|
|
|
|
// commentSourceTaskIDForIssue returns the agent's currently-executing task (from
|
|
// the X-Task-ID header) when it is running on the given issue, else an invalid
|
|
// UUID. This is the exact issue-scoped lineage CreateComment stamps onto
|
|
// source_task_id; a cross-issue (or missing) task yields invalid so the persisted
|
|
// lineage — and every authority/originator resolution that reads it — fails closed
|
|
// (MUL-4857).
|
|
func (h *Handler) commentSourceTaskIDForIssue(r *http.Request, issue db.Issue) pgtype.UUID {
|
|
task, ok := h.taskFromRequestHeader(r)
|
|
if !ok || !task.IssueID.Valid || uuidToString(task.IssueID) != uuidToString(issue.ID) {
|
|
return pgtype.UUID{}
|
|
}
|
|
return task.ID
|
|
}
|
|
|
|
// taskFromRequestHeader resolves the agent's currently-executing task from the
|
|
// X-Task-ID header (set by the CLI on every request). Returns ok=false when the
|
|
// header is absent, malformed, or names no existing task.
|
|
func (h *Handler) taskFromRequestHeader(r *http.Request) (db.AgentTaskQueue, bool) {
|
|
taskIDHeader := r.Header.Get("X-Task-ID")
|
|
if taskIDHeader == "" {
|
|
return db.AgentTaskQueue{}, false
|
|
}
|
|
taskUUID, err := util.ParseUUID(taskIDHeader)
|
|
if err != nil {
|
|
return db.AgentTaskQueue{}, false
|
|
}
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
|
if err != nil {
|
|
return db.AgentTaskQueue{}, false
|
|
}
|
|
return task, true
|
|
}
|
|
|
|
// 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 / non-allow-listed 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
|
|
}
|
|
targetsByAgent, ok := h.loadInvocationTargetsByAgent(ctx, agents)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
allowed := make(map[string]struct{}, len(agents))
|
|
for _, a := range agents {
|
|
if actorType == "member" {
|
|
if !memberAllowedToViewAgent(a, targetsByAgent[uuidToString(a.ID)], actorID, role) {
|
|
continue
|
|
}
|
|
}
|
|
allowed[uuidToString(a.ID)] = struct{}{}
|
|
}
|
|
return allowed, true
|
|
}
|
|
|
|
// loadInvocationTargetsByAgent batch-loads invocation targets for a set of
|
|
// agents and buckets them by agent id string. Avoids the per-agent query the
|
|
// list / aggregation paths would otherwise incur.
|
|
func (h *Handler) loadInvocationTargetsByAgent(ctx context.Context, agents []db.Agent) (map[string][]db.AgentInvocationTarget, bool) {
|
|
ids := make([]pgtype.UUID, 0, len(agents))
|
|
for _, a := range agents {
|
|
ids = append(ids, a.ID)
|
|
}
|
|
out := make(map[string][]db.AgentInvocationTarget, len(agents))
|
|
if len(ids) == 0 {
|
|
return out, true
|
|
}
|
|
rows, err := h.Queries.ListAgentInvocationTargetsByAgentIDs(ctx, ids)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
for _, row := range rows {
|
|
aid := uuidToString(row.AgentID)
|
|
out[aid] = append(out[aid], row)
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// canEnqueueSquadLeader returns true when the given actor is allowed to
|
|
// trigger the squad's private leader. It loads the leader agent and delegates
|
|
// to canInvokeAgent so the leader-trigger path honours invocation permission
|
|
// exactly like a direct assignment/mention. Non-public leaders require owner /
|
|
// allow-list; system-initiated triggers (e.g. github webhooks) are judged as
|
|
// system principals (workspace target only).
|
|
func (h *Handler) canEnqueueSquadLeader(ctx context.Context, leaderID pgtype.UUID, actorType, actorID, originatorUserID, workspaceID string) bool {
|
|
agent, err := h.Queries.GetAgent(ctx, leaderID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return h.canInvokeAgent(ctx, agent, actorType, actorID, originatorUserID, workspaceID)
|
|
}
|