mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(autopilot): carry the trigger owner's authorization on scheduled runs A schedule/webhook autopilot run left originator_user_id NULL, so canInvokeAgent saw no human and fail-closed against private agents. Arming a trigger is the same authorizing act as clicking "run now" — deferred, not absent — so withholding the originator did not remove that authority. It only forced every capability the automation legitimately needed to arrive as a separately-bounded borrow path: MUL-4857 for child creation, MUL-6691 for the run's own issues, #7902 for the accountable member's issues, with sub-issue dispatch queued up as the fourth. Each carries a hand-written scope that no operator can see in the UI. attribution.TriggerOwner / RuleOwner now populate UserID (the originator) instead of only the accountable side. finalizeAttribution mirrors it, so the originator == accountable invariant that migrations 190/197 enforce holds without a schema change or backfill. Both autopilot execution modes resolve through triggerOwnerAttribution, so run_only and create_issue are covered by the same two lines. This grants exactly the trigger owner's own rights — canInvokeAgent is untouched, only the principal handed to it changes — and originator_source still distinguishes a scheduled fire from a manual one in the audit trail. The borrowed-authority machinery in handler/agent_access.go is now dead for newly enqueued runs but is deliberately left in place: tasks enqueued by the previous backend are still running during a rolling deploy and carry originator NULL, and they must keep the path they were admitted under. Deleting it is a follow-up once this is deployed. Composio connected-app credentials also key on originator_user_id, so scheduled runs now receive the trigger owner's connected apps. Decoupling that from authorization was considered and explicitly deferred. Verification (real PostgreSQL 17): - internal/attribution, internal/service, internal/handler: PASS - the five attribution_stamp tests that pinned "originator stays NULL" were updated to assert the new principal, and fail against the old constructors - pre-existing, unrelated failures in internal/daemon/execenv (4 Hermes store-path tests) reproduce identically on a clean tree Co-authored-by: multica-agent <[email protected]> * refactor(autopilot): delete the borrowed-authority machinery With a scheduled run carrying its trigger owner as originator, every autopilot-shaped authority path in agent_access.go is unreachable: the invoke gate resolves a real human from the speaking task and judges it exactly as it judges that human acting directly. Removed: - autopilotTaskAssignAuthority + autopilotAssignAuthorityFromRequest (MUL-6691) - issueBoundToAutopilotTask, taskRunsAutopilotInWorkspace - autopilotDelegationAuthority and its FromRequest / FromComment resolvers (MUL-4857) - effectiveInvocationAuthorityFromRequest - assignAuthorityScope, assignScopeKind and the four scope constructors - commentTriggerComputeOptions.AutopilotDelegationAuthorityUserID + effectiveInvoker() validateAssigneePair and triggerTasksForComment lose their now-meaningless scope / delegation-authority parameters; every caller passes the run's originator instead. This also retires the fourth borrow scope #7902 proposed. CreateIssue still loads the parent issue when assigning, even though the gate no longer needs the row: it is what turns a cross-workspace or missing parent into 400 "parent issue not found" instead of a 403. Covered by a test. Two behaviour notes, both accepted: - Authority now propagates down a delegation chain, because the parent run finally has an originator to copy. Under the old design each hop had to re-borrow. This matches what a manual "run now" already did. - The old borrow path required task.Status == "running"; the originator path does not, and never did for any non-autopilot run. Task tokens are revoked at the terminal transition (TaskService, MUL-2600), so the primary guard remains; this only drops a second layer that existed solely for autopilot runs. Tests: the two suites that pinned the borrow bounds (autopilot-created-issue binding, authored-by-this-task binding) described a mechanism that no longer exists, so they collapse into one file asserting the rule that replaced them — rights / no rights / no human at all, across create-top-level, create-child, update and batch. The mention-authority suite keeps its shape; its fixture now gives the run a human, as a live run has. Verification (real PostgreSQL 17): internal/attribution, internal/service and internal/handler all PASS. Pre-existing unrelated failures in cmd/multica, internal/cli, internal/daemon and internal/daemon/execenv reproduce identically on a clean tree. Co-authored-by: multica-agent <[email protected]> * fix(autopilot): resolve one principal per dispatch and expire it with the run Addresses Elon's review of #7918. Three findings, all confirmed against the code before fixing. P1-1 — admission and the run's identity could be two different people. Automatic dispatch admitted the assignee agent as the AUTOPILOT CREATOR (canCreatorInvokeAgent) while the task took its originator from the trigger. With A owning both the autopilot and a private agent, and B owning the trigger, a dispatch was admitted as A and then executed as B — rights neither holds alone. The old suite could not see it because it called dispatchRunOnly directly, skipping admission. - autopilot_trigger gains an IMMUTABLE created_by (migration 449, best-effort backfill from published_by). published_by keeps its audit meaning and still transfers on a substantive edit; it is no longer an authorization input, because letting an editor move it silently re-authorizes the automation as that editor. Per Bohan's ruling the run always acts as the trigger CREATOR. - ResolveAutopilotTriggerPrincipal is now the single answer to "who does this run act as". Admission, the task stamp and every delegated run read it, so the fork is unrepresentable. It also binds the trigger to its autopilot (GetAutopilotTriggerForAutopilot — the old lookup was WHERE id = $1, which would let a foreign trigger select the principal) and re-checks workspace membership on every dispatch, so removing a member revokes their triggers. - canCreatorInvokeAgent is now unreachable and deleted. - RuleOwner reverts to accountable-only. It is a guess at who owns a rule; promoting it would hand a legacy trigger somebody's rights without that person arming anything. Such runs carry no originator and fail closed. P1-2 — a terminal task could still spend its human's rights. invokeOriginatorFromRequest now refuses completed / failed / cancelled tasks. Token revocation at the terminal transition stays the primary guard, but it is best-effort, explicitly non-fatal on failure, with a 24h expiry behind it. The check sits at the authorization boundary rather than the auth boundary, so a finishing run can still post its final comment but cannot start new work. P1-3 — the PR and code comments described Composio backwards. BuildTaskOverlay ignores the originator entirely and builds from the AGENT OWNER's connections and allow-list (MUL-3963). Naming the originator hands out no third-party credentials. Comments corrected; no decoupling was ever needed. Operational note: a trigger with neither created_by nor published_by (predating migration 186) resolves no principal, so its autopilot stops dispatching until the trigger is re-saved. That is the intended fail-closed behaviour. Tests: full dispatch through admission for both fork directions; principal fails closed on legacy / cross-autopilot / removed-member; terminal task refused on all three statuses; the edit-transfer tests now assert the principal STAYS with the creator while published_by moves. Verified the fork test fails when admission is reverted to canCreatorInvokeAgent. Verification (real PostgreSQL 17, pristine database): internal/attribution, internal/service, internal/handler and cmd/server all PASS. Migration 449 up/down round-trips. Pre-existing unrelated failures in cmd/multica, internal/cli, internal/daemon and internal/daemon/execenv are unchanged. Co-authored-by: multica-agent <[email protected]> * fix(autopilot): bind the trigger principal to its workspace, correct stale docs Second round of Elon's review on #7918. 1. The trigger lookup only enforced half of what its comment claimed. GetAutopilotTriggerForAutopilot matched on (trigger id, autopilot id); the workspace was used only for the creator's membership check afterwards. That check proves the resolved human belongs to the workspace PASSED IN, not that the autopilot does — a member of two workspaces satisfies it while the trigger came from the other tenant. The query now joins autopilot and filters on workspace_id, and the resolver passes it through. Regression test seeds two workspaces sharing one member and asserts the foreign workspace resolves nobody; verified it fails against the pre-fix query. 2. Several permission-boundary comments still described the pre-MUL-6951 model — that a run's authority and its accountable human transfer to whoever last edited the trigger. That is now the opposite of the code, and these are the comments a future reader would use to reason about the authorization model, so they are corrected at all four sites: the trigger create site, the SetAutopilotTriggerPublisher query, triggerOwnerAttribution, and the dispatchRunOnly attribution block. published_by is described as what it now is: a config-audit column with no bearing on a run. 3. The migration comment now states the backfill's imprecision plainly: for an already-edited trigger it freezes the last recoverable EDITOR as the immutable creator, not the historical creator, which the schema never recorded. It also records that a NULL/NULL trigger has no recovery path by decision (Bohan: leave them empty) — re-saving re-stamps published_by, not created_by — so its runs stay refused with a recorded failure_reason. Not done, per Bohan's ruling: no re-save takeover path for legacy triggers and no migration-period alerting. Verification (real PostgreSQL 17, pristine database): internal/attribution, internal/service, internal/handler and cmd/server PASS; full suite shows only the four pre-existing unrelated failures (cmd/multica, internal/cli, internal/daemon, internal/daemon/execenv). go vet ./internal/... and git diff --check clean. Co-authored-by: multica-agent <[email protected]> * docs(autopilot): carry the creator-principal semantics into every description Third round of Elon's review on #7918. Two must-fixes, no behaviour change. 1. The final semantics had not reached every surface that describes them. Migration 189 wrote COMMENT ON COLUMN text for autopilot_trigger.published_by_* saying it decides the accountable human of the runs a trigger fires. That is now false, and because sqlc generates column comments into models.go, the stalest description of the authorization model sat in generated code every reader passes through. 189 is released and untouched; migration 449 overrides both comments and documents created_by_* for the first time, and sqlc is regenerated. 449's down restores 189's original text verbatim so a rollback does not leave MUL-6951 wording describing a model the database no longer runs. The same stale semantics remained in prose at six code sites — the plain and manual dispatch entry points, the scheduled-plan path, the create_issue enqueue comment, the autopilot-level republish path, and the webhook trigger create site. All said a run's authority or accountable human follows the last editor, or that automation resolves rule_owner. These sit on the authorization path, so they are corrected rather than left to drift. published_by is now described as what it is: config audit, with no bearing on a run. Also corrects the backfill comment's migration reference: trigger publishers arrived in 189, not 186. 2. The test file this PR added open-coded INSERT ... RETURNING id with matching t.Cleanup(DELETE ...), which CLAUDE.md prohibits. Rewritten onto testutil.Fixture: a small principalFixture embeds it and every row — members, runtimes, private agents, autopilots, triggers, the second workspace — is built and cleaned up by dbfx. The only remaining DELETE is the mid-test member removal that IS the behaviour under test. Re-verified after the rewrite that both regressions still fail against the old code: reverting admission to the autopilot-creator check reddens the fork test, and dropping the workspace join reddens the cross-workspace test. Verification (real PostgreSQL 17, pristine database): full suite shows only the four pre-existing unrelated failures (cmd/multica, internal/cli, internal/daemon, internal/daemon/execenv). Migration 449 up/down round-trips and the down restores 189's comments. go vet ./internal/... and git diff --check clean. Co-authored-by: multica-agent <[email protected]> * docs(autopilot): state the backfill widening plainly, drop raw-SQL test seeding Fourth round of Elon's review on #7918. Both items are leftovers from the previous round; no behaviour change. 1. Migration 449's backfill comment carried two factual errors. It said published_by_* arrived in migration 186; it was 189. The previous round intended to fix this but the string replacement did not match the wrapped line and silently did nothing, so the wrong number shipped. It also claimed the backfill "is no wider than the authority those runs carry today", which is false: before MUL-6951 an automatic run had no originator at all, only narrowly-scoped borrow paths, so promoting a backfilled editor to the run's full originator IS a widening. The product decision to accept it stands; the comment now says so directly as a compatibility tradeoff instead of denying the widening. A migration comment that understates what it grants is worse than no comment. 2. The new test file still reached raw SQL transitively. It called seedAttributionFixture, which hand-writes INSERT and cleanup for its user / workspace / member / runtime / agent — so the file's own claim that "every row goes through testutil.Fixture" was untrue and the repository's fixture rule was still unmet. It now bootstraps the base identity with Fixture.User / Workspace / Member on an unbound fixture and adopts it, which also drops the agent and issue that helper builds and these tests never use. The comment explains why the helper is deliberately not reused. Re-verified after the fixture change that both regressions still fail against the old code: reverting admission to the autopilot-creator check reddens the fork test, and dropping the workspace join reddens the cross-workspace test. Verification (real PostgreSQL 17, pristine database): full suite shows only the four pre-existing unrelated failures (cmd/multica, internal/cli, internal/daemon, internal/daemon/execenv). go vet ./internal/..., gofmt and git diff --check clean. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
417 lines
17 KiB
Go
417 lines
17 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"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 {
|
|
allowed := h.invokeAgentDecision(ctx, agent, actorType, actorID, originatorUserID, workspaceID)
|
|
if !allowed && actorType != "member" && originatorUserID == "" {
|
|
// MUL-6490: the wire reason stays the deliberately generic
|
|
// invocation_not_allowed (dispatch/reason.go — it must not reveal whether a
|
|
// target exists), which leaves the one denial a workspace CAN fix looking
|
|
// identical to a plain permission error. Name the root cause here, where
|
|
// only operators read it: the chain reached this gate with no human at its
|
|
// top, so member-scoped allow-lists could not match anyone. Without this
|
|
// line the reported failure mode (a broken originator chain N hops back)
|
|
// was only diagnosable by querying agent_task_queue directly.
|
|
slog.WarnContext(ctx, "agent invoke denied: delegation chain carries no human originator",
|
|
"agent_id", uuidToString(agent.ID),
|
|
"agent_permission_mode", agent.PermissionMode,
|
|
"actor_type", actorType,
|
|
"actor_id", actorID,
|
|
"workspace_id", workspaceID,
|
|
)
|
|
}
|
|
return allowed
|
|
}
|
|
|
|
// invokeAgentDecision is canInvokeAgent's pure verdict, split out so the gate has
|
|
// exactly one place to observe a denial from.
|
|
func (h *Handler) invokeAgentDecision(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.
|
|
//
|
|
// A TERMINAL task lends nothing (MUL-6951, Elon review). A run represents its
|
|
// human only while it is live: once the task is completed / failed / cancelled,
|
|
// the work that human authorized is over. Task-token revocation at the terminal
|
|
// transition is the primary guard, but it is best-effort and explicitly non-fatal
|
|
// on failure (TaskService, MUL-2600) with a 24h token expiry behind it, so a
|
|
// revocation that fails would otherwise leave a window in which a finished run
|
|
// keeps spending a member's invoke rights. This check closes that window at the
|
|
// authorization boundary rather than the auth boundary, so a terminal task can
|
|
// still complete ordinary API work (posting its final comment) without being able
|
|
// to start new work as its human.
|
|
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 {
|
|
if isTerminalTaskStatus(task.Status) {
|
|
return ""
|
|
}
|
|
return uuidToString(task.OriginatorUserID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isTerminalTaskStatus reports whether a task has finished, in any of the three
|
|
// ways it can. Mirrors the agent_task_queue status CHECK constraint; a status the
|
|
// constraint does not know is treated as NOT terminal so an added in-flight state
|
|
// keeps working (an added terminal state would need adding here, which the invoke
|
|
// gate's fail-closed default makes visible rather than silent).
|
|
func isTerminalTaskStatus(status string) bool {
|
|
switch status {
|
|
case "completed", "failed", "cancelled":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// commentSourceTaskID returns the agent's currently-executing task (from the
|
|
// server-trusted X-Task-ID header), else an invalid UUID. This is the exact
|
|
// lineage CreateComment stamps onto source_task_id, so an edit re-stamps what a
|
|
// fresh authoring of the same content would have stamped and the two resolvers
|
|
// can never disagree (MUL-6490).
|
|
//
|
|
// The task is NOT required to be running on the edited comment's issue: the
|
|
// stamp records "which run wrote this", and a run may legitimately write on
|
|
// another issue. That is safe for the invoke gate because the gate keys on the
|
|
// task's own originator (MUL-6951) — a human who already holds those rights —
|
|
// rather than on a same-issue binding standing in for one.
|
|
func (h *Handler) commentSourceTaskID(r *http.Request) pgtype.UUID {
|
|
task, ok := h.taskFromRequestHeader(r)
|
|
if !ok {
|
|
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
|
|
}
|
|
|
|
// restrictedAgentIDs returns the ids of agents that EXIST in the workspace but
|
|
// must not be named to this actor. Aggregation endpoints that cannot simply drop
|
|
// a row (the dashboard rollups, whose per-agent totals have to keep reconciling
|
|
// with the workspace-level series rendered beside them) use it to fold those
|
|
// agents onto a single sentinel instead.
|
|
//
|
|
// Two populations land in the set, for two different reasons:
|
|
//
|
|
// 1. Every `kind != "user"` agent — the hidden execution carriers behind agent
|
|
// builder sessions. These are restricted for EVERYONE, owner and admin
|
|
// included: no list endpoint returns them (ListAgents / ListAllAgents filter
|
|
// on kind), so no client can ever resolve one to a name, yet they run real
|
|
// tasks and book real usage that the rollups aggregate all the same. Left
|
|
// alone they surface as a bare UUID whose spend and failure profile belong
|
|
// to whoever opened that builder session.
|
|
// 2. For a plain member only, the user agents they may not view.
|
|
//
|
|
// Hard-deleted agents are deliberately NOT in the set: they are already absent
|
|
// from the agent table, have no visibility left to protect, and the dashboard
|
|
// renders them as their own "deleted agents" bucket — folding them in here
|
|
// would relabel a real deletion as a permission boundary.
|
|
//
|
|
// Because of (1) this always reads the agent table, but the per-agent
|
|
// invocation-target lookup is skipped for actors who see every user agent
|
|
// (agent actors, workspace owner/admin) since nothing there can change their
|
|
// answer.
|
|
func (h *Handler) restrictedAgentIDs(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.ListAllAgentsAnyKind(ctx, wsUUID)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
// Whether per-agent visibility can restrict anything for this actor at all.
|
|
judgeUserAgents := actorType == "member" && !roleAllowed(role, "owner", "admin")
|
|
var targetsByAgent map[string][]db.AgentInvocationTarget
|
|
if judgeUserAgents {
|
|
var ok bool
|
|
if targetsByAgent, ok = h.loadInvocationTargetsByAgent(ctx, agents); !ok {
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
restricted := make(map[string]struct{})
|
|
for _, a := range agents {
|
|
if a.Kind == "user" {
|
|
if !judgeUserAgents ||
|
|
memberAllowedToViewAgent(a, targetsByAgent[uuidToString(a.ID)], actorID, role) {
|
|
continue
|
|
}
|
|
}
|
|
restricted[uuidToString(a.ID)] = struct{}{}
|
|
}
|
|
return restricted, 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)
|
|
}
|