Files
multica/server/internal/handler/agent_access.go
Bohan Jiang 69744241e8 fix(comment): restore autopilot @mention delegation authority (MUL-4857) (#5512)
* 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>
2026-07-16 15:23:56 +08:00

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)
}