import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { withHumanDirectedWork } from "./human-directed-work.js"; import { hasOwnerChatInstructionAuthority } from "./owner-chat-instruction-authority.js"; import { agents, approvals, issueApprovals, authUsers, companyMemberships, chatConversations, chatDeliveries, chatIdentityLinks, heartbeatRuns, executionWorkspaces, instanceUserRoles, issueComments, issueAccessGrants, issues, principalPermissionGrants, projectAccessMembers, projects, userInboxAgentPolicies, } from "@paperclipai/db"; import type { AgentApiKeyScope, InboxAgentPolicyMode, PermissionKey, PrincipalType, SkillTestAgentKeyScope, TaskBridgeAgentKeyScope, } from "@paperclipai/shared"; import { LOW_TRUST_REVIEW_PRESET, extractAgentMentionIds, trustPresetSchema, lowTrustBoundarySchema, lowTrustReviewPresetPolicySchema, type LowTrustBoundary, } from "@paperclipai/shared"; import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, isIssueWithinLowTrustBoundary, resolveCoreTrustPreset, type TrustPresetResolution, } from "./trust-preset-resolver.js"; import { logger } from "../middleware/logger.js"; import { normalizeAgentPermissions } from "../lib/agent-permissions.js"; import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js"; export type AuthorizationActor = { type: "board" | "agent" | "none"; userId?: string | null; sessionId?: string | null; companyIds?: string[]; memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>; onBehalfOfMemberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>; isInstanceAdmin?: boolean; ignoreInstanceAdmin?: boolean; agentId?: string | null; companyId?: string | null; keyId?: string | null; keyScope?: AgentApiKeyScope | null; runId?: string | null; onBehalfOfUserId?: string | null; source?: | "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "mcp_oauth" | "cloud_tenant" | "cloud_control" | "none"; }; export type AuthorizationAction = | PermissionKey | "agent_config:read" | "agent_config:update" | "agent_instructions:update" | "skill_config:update" | "agent:read" | "agent:wake" | "company_scope:read" | "decision_queue:manage" | "decision_queue:read" | "decision_triage:manage" | "issue:comment" | "issue:mutate" | "issue:read" | "project:read" | "runtime:manage" | "secrets:read" | "secrets:propose"; export type AuthorizationResource = | { type: "company"; companyId: string } | { type: "agent"; companyId: string; agentId?: string | null } | { type: "project"; companyId: string; projectId?: string | null } | { type: "issue"; companyId: string; issueId?: string | null; projectId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; originKind?: string | null; originId?: string | null; status?: string | null; }; export type AuthorizationDecision = { allowed: boolean; action: AuthorizationAction; explanation: string; inboxPolicyMode?: InboxAgentPolicyMode | "grant_override"; code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE"; reason: | "allow_low_trust_boundary" | "allow_local_board" | "allow_instance_admin" | "allow_explicit_grant" | "allow_role_default" | "allow_user_inbox_policy" | "allow_direct_change" | "allow_consented_change" | "allow_legacy_agent_creator" | "allow_issue_mention_grant" | "allow_direct_parent_report" | "allow_visible_issue_write" | "allow_self" | "allow_company_agent" | "allow_company_member" | "allow_simple_company_member" | "allow_manager_chain" | "inbox_target_user_unresolved" | "inbox_management_disabled" | "inbox_agent_not_allowed" | "deny_unauthenticated" | "deny_company_boundary" | "deny_missing_membership" | "deny_missing_grant" | "deny_missing_consent" | "deny_no_grant" | "deny_policy_restricted" | "deny_low_trust_boundary" | "deny_issue_private" | "deny_project_private" | "deny_scope" | "deny_unsupported_action"; grant?: { principalType: PrincipalType; principalId: string; permissionKey: PermissionKey; scope: Record | null; }; }; type PrincipalGrantDecision = AuthorizationDecision & { grant?: NonNullable; }; function companyIdForResource(resource: AuthorizationResource) { return resource.companyId; } function permissionForAction(action: AuthorizationAction): PermissionKey | null { if (action === "agent_config:read" || action === "agent_config:update" || action === "agent_instructions:update" || action === "skill_config:update") { return null; } if ( action === "agent:read" || action === "agent:wake" || action === "company_scope:read" || action === "decision_queue:manage" || action === "decision_queue:read" || action === "decision_triage:manage" || action === "issue:read" || action === "project:read" || action === "runtime:manage" || action === "secrets:read" || action === "secrets:propose" ) { return null; } if (action === "issue:comment" || action === "issue:mutate") return null; return action; } function canCreateAgentsLegacy(agent: { role: string; permissions: unknown }) { if (agent.role === "ceo") return true; // Raw agent rows may predate permission normalization; apply the same // defaults the agent service applies on read so enforcement matches what // the API reports. return normalizeAgentPermissions(agent.permissions).canCreateAgents; } function scopeValueList(value: unknown): string[] { if (typeof value === "string" && value.trim()) return [value.trim()]; if (!Array.isArray(value)) return []; return value .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) .map((entry) => entry.trim()); } function prefixedScopeValues(grantScope: Record, prefix: string) { return scopeValueList(grantScope.allow) .filter((rule) => rule.startsWith(prefix)) .map((rule) => rule.slice(prefix.length)) .filter((value) => value.length > 0); } function scopeValuesForKeys(grantScope: Record, keys: string[]) { return keys.flatMap((key) => scopeValueList(grantScope[key])); } function scopeIncludesId(ids: string[], id: string | null | undefined) { return Boolean(id && ids.includes(id)); } function isSimpleAssignableAgentStatus(status: string | null | undefined) { return status !== "pending_approval" && status !== "terminated"; } function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function objectIsEmpty(value: Record) { return Object.keys(value).length === 0; } function readPolicyObject(container: unknown, key: string): Record | null { if (!isPlainRecord(container)) return null; const value = container[key]; return isPlainRecord(value) ? value : null; } function readString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function readBoolean(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } type AssignmentPolicyEffect = | { kind: "none" } | { kind: "restricted"; explanation: string } | { kind: "blocked"; explanation: string } | { kind: "unknown"; explanation: string }; type AgentHierarchyRow = { id: string; reportsTo: string | null }; type LowTrustBoundaryWithCompany = LowTrustBoundary & { companyId: string }; type AgentAuthorizationRow = { id: string; companyId: string; role: string; status: string; reportsTo: string | null; permissions: Record | null | undefined; }; type ProjectAuthorizationRow = { id: string; companyId: string; executionWorkspacePolicy: unknown; }; type IssueAuthorizationRow = { id: string; companyId: string; projectId?: string | null; parentId: string | null; assigneeAgentId: string | null; assigneeUserId: string | null; checkoutRunId: string | null; status: string; executionPolicy: unknown; originKind: string | null; originId: string | null; }; function evaluateAuthorizationPolicyForAssignment( policy: Record | null | undefined, label: string, ): AssignmentPolicyEffect { if (!policy || objectIsEmpty(policy)) return { kind: "none" }; const agentVisibility = readPolicyObject(policy, "agentVisibility"); const assignmentPolicy = readPolicyObject(policy, "assignmentPolicy"); const protectedAgent = readPolicyObject(policy, "protectedAgent"); // Core containment policy is understood independently of assignment policy. // Recognizing it must not hide malformed values or unknown extension rules. const trustSections = [ ["trustPreset", trustPresetSchema], ["trustBoundary", lowTrustBoundarySchema], ["reviewPreset", lowTrustReviewPresetPolicySchema], ] as const; const hasTrustPolicy = trustSections.some(([key]) => policy[key] !== undefined); const hasInvalidTrustPolicy = trustSections.some(([key, schema]) => policy[key] !== undefined && !schema.safeParse(policy[key]).success, ); const knownTopLevelKeys = new Set([ "agentVisibility", "assignmentPolicy", "protectedAgent", "managedBy", ...trustSections.map(([key]) => key), ]); const hasUnknownTopLevelKey = Object.keys(policy).some((key) => !knownTopLevelKeys.has(key)); const hasKnownPolicySection = Boolean(agentVisibility || assignmentPolicy || protectedAgent || hasTrustPolicy); if (hasUnknownTopLevelKey || hasInvalidTrustPolicy || !hasKnownPolicySection) { return { kind: "unknown", explanation: `${label} has authorization policy data that core cannot evaluate for task assignment.`, }; } const visibilityMode = readString(agentVisibility?.mode); if (visibilityMode && visibilityMode !== "discoverable" && visibilityMode !== "private") { return { kind: "unknown", explanation: `${label} has an unsupported agent visibility policy mode.`, }; } const assignmentMode = readString(assignmentPolicy?.mode); if (assignmentMode && assignmentMode !== "company_default" && assignmentMode !== "protected") { return { kind: "unknown", explanation: `${label} has an unsupported assignment policy mode.`, }; } // `requiresApproval` and `protectedAgentRequiresApproval` are legacy aliases. // They never had an approval workflow behind them, so preserve their hard-block // behavior without continuing to promise an approval step that does not exist. const blockAssignment = readBoolean(protectedAgent?.blockAssignment) === true || readBoolean(protectedAgent?.requiresApproval) === true || readBoolean(assignmentPolicy?.protectedAgentRequiresApproval) === true; if (blockAssignment) { return { kind: "blocked", explanation: `${label} assignment is blocked by protected-agent policy. ` + "A company administrator can remove the assignment block, then retry.", }; } if ( visibilityMode === "private" || readBoolean(agentVisibility?.hiddenFromDefaultDirectory) === true ) { return { kind: "restricted", explanation: `${label} is private and cannot use simple company-wide task assignment.`, }; } if (assignmentMode === "protected") { return { kind: "restricted", explanation: `${label} is protected and requires an explicit assignment grant.`, }; } return { kind: "none" }; } function agentIsInSubtree( agentsById: Map, rootAgentId: string, targetAgentId: string, ) { if (rootAgentId === targetAgentId) return true; let cursor: string | null = targetAgentId; for (let depth = 0; cursor && depth < 50; depth += 1) { const current = agentsById.get(cursor); if (!current) return false; if (current.reportsTo === rootAgentId) return true; cursor = current.reportsTo; } return false; } async function loadCompanyAgentHierarchy(db: Db | DbTransaction, companyId: string) { const rows = await db .select({ id: agents.id, reportsTo: agents.reportsTo }) .from(agents) .where(eq(agents.companyId, companyId)); return new Map(rows.map((agent) => [agent.id, agent])); } async function isAgentInSubtree( db: Db | DbTransaction, companyId: string, rootAgentId: string, targetAgentId: string, ) { return agentIsInSubtree( await loadCompanyAgentHierarchy(db, companyId), rootAgentId, targetAgentId, ); } async function scopeAllows( db: Db | DbTransaction, companyId: string, grantScope: Record | null, requestedScope: Record | null | undefined, options: { requireStructuredScope?: boolean } = {}, ) { if (!grantScope || Object.keys(grantScope).length === 0) return !options.requireStructuredScope; if (!requestedScope) return false; const targetAssigneeAgentId = typeof requestedScope.assigneeAgentId === "string" ? requestedScope.assigneeAgentId : typeof requestedScope.targetAgentId === "string" ? requestedScope.targetAgentId : null; const requestedProjectId = typeof requestedScope.projectId === "string" ? requestedScope.projectId : null; const requestedUserId = typeof requestedScope.userId === "string" ? requestedScope.userId : null; let constrained = false; const projectIds = [ ...scopeValueList(grantScope.projectId), ...scopeValueList(grantScope.projectIds), ...prefixedScopeValues(grantScope, "project:"), ]; if (projectIds.length > 0) { constrained = true; if (!scopeIncludesId(projectIds, requestedProjectId)) return false; } const targetAgentIds = [ ...scopeValuesForKeys(grantScope, [ "agentId", "agentIds", "assigneeAgentId", "assigneeAgentIds", "targetAgentId", "targetAgentIds", ]), ...prefixedScopeValues(grantScope, "agent:"), ]; if (targetAgentIds.length > 0) { constrained = true; if (!scopeIncludesId(targetAgentIds, targetAssigneeAgentId)) return false; } const targetUserIds = scopeValuesForKeys(grantScope, ["userId", "userIds"]); if (targetUserIds.length > 0) { constrained = true; if (!scopeIncludesId(targetUserIds, requestedUserId)) return false; } const subtreeRootAgentIds = [ ...scopeValuesForKeys(grantScope, [ "managerAgentId", "managerAgentIds", "managedSubtreeAgentId", "managedSubtreeAgentIds", "subtreeAgentId", "subtreeAgentIds", "subtreeRootAgentId", "subtreeRootAgentIds", ]), ...prefixedScopeValues(grantScope, "subtree:"), ]; if (subtreeRootAgentIds.length > 0) { constrained = true; if (!targetAssigneeAgentId) return false; const agentsById = await loadCompanyAgentHierarchy(db, companyId); let matchesSubtree = false; for (const rootAgentId of subtreeRootAgentIds) { if (agentIsInSubtree(agentsById, rootAgentId, targetAssigneeAgentId)) { matchesSubtree = true; break; } } if (!matchesSubtree) return false; } // Unknown metadata keys do not constrain the grant. Recognized constraints // return false above when they fail to match the requested assignment scope. return !constrained ? true : constrained; } function allow(input: Omit): AuthorizationDecision { return { ...input, allowed: true }; } function deny(input: Omit): AuthorizationDecision { return { ...input, allowed: false }; } type ResponsibleUserSnapshot = { userId: string; companyId: string; userExists: boolean; activeMembership: { companyId: string; membershipRole?: string | null; status?: string } | null; }; type ResponsibleUserActorWithMemo = AuthorizationActor & { __responsibleUserSnapshotMemo?: Map>; }; const responsibleUserSnapshotCache = new Map< string, { expiresAt: number; promise: Promise } >(); export type IssuePrivacyRow = { id: string; companyId: string; visibility: string; privacyRootIssueId: string | null; responsibleUserId: string | null; createdByUserId: string | null; assigneeUserId: string | null; assigneeAgentId: string | null; projectId?: string | null; }; export type ProjectPrivacyRow = { id: string; companyId: string; visibility: string; }; export function issuePrivacyMode(): "off" | "shadow" | "enforce" { const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase(); if (mode === "off" || mode === "shadow") return mode; return "enforce"; } function issuePrivacyPrincipal(actor: AuthorizationActor) { if (actor.type === "board" && actor.userId) return { type: "user" as const, id: actor.userId }; if (actor.type === "agent" && actor.agentId) return { type: "agent" as const, id: actor.agentId }; return null; } function projectPrivacyPrincipalCondition(actor: AuthorizationActor): SQL { const principal = issuePrivacyPrincipal(actor); return sql`(${projects.visibility} = 'open' or ${principal ? sql`exists ( select 1 from ${projectAccessMembers} m where m.company_id = ${projects.companyId} and m.project_id = ${projects.id} and m.subject_type = ${principal.type} and m.subject_id = ${principal.id})` : sql`false`})`; } function projectPrivacyCondition(actor: AuthorizationActor): SQL { const own = projectPrivacyPrincipalCondition(actor); if (actor.type !== "agent" || !actor.onBehalfOfUserId) return own; return sql`(${own} and ${projectPrivacyPrincipalCondition({ type: "board", userId: actor.onBehalfOfUserId })} and exists (select 1 from ${companyMemberships} m where m.company_id = ${projects.companyId} and m.principal_type = 'user' and m.principal_id = ${actor.onBehalfOfUserId} and m.status = 'active'))`; } export async function canActorReadProjectPrivacy(db: Db | DbTransaction, actor: AuthorizationActor, project: ProjectPrivacyRow) { return db.select({ id: projects.id }).from(projects) .where(and(eq(projects.id, project.id), eq(projects.companyId, project.companyId), projectPrivacyCondition(actor))) .limit(1).then(rows => rows.length > 0); } /** One predicate for direct reads and SQL pushdown. Grants flow only from a task * to its privacy descendants, never from a child to its ancestors or siblings. * UNION bounds traversal even if historical data contains a cycle. */ function issuePrivacyPrincipalCondition(actor: AuthorizationActor): SQL { const principal = issuePrivacyPrincipal(actor); const implicit = principal?.type === "agent" ? sql`n.assignee_agent_id = ${principal.id}` : principal?.type === "user" ? sql`(n.responsible_user_id = ${principal.id} or n.created_by_user_id = ${principal.id} or n.assignee_user_id = ${principal.id})` : sql`false`; const grant = principal ? sql`exists ( select 1 from ${issueAccessGrants} g where g.issue_id = n.id and g.subject_type = ${principal.type} and g.subject_id = ${principal.id} and g.revoked_at is null )` : sql`false`; const projectMember = principal ? sql`exists ( select 1 from ${projectAccessMembers} m join ${projects} p on p.id = m.project_id and p.company_id = m.company_id where p.id = n.project_id and p.company_id = n.company_id and p.visibility = 'private' and m.subject_type = ${principal.type} and m.subject_id = ${principal.id} )` : sql`false`; return sql`exists ( with recursive privacy_ancestors as ( select p.id, p.company_id, p.privacy_parent_issue_id, p.visibility, p.project_id, p.responsible_user_id, p.created_by_user_id, p.assignee_user_id, p.assignee_agent_id from issues p where p.id = ${issues.id} and p.company_id = ${issues.companyId} union select p.id, p.company_id, p.privacy_parent_issue_id, p.visibility, p.project_id, p.responsible_user_id, p.created_by_user_id, p.assignee_user_id, p.assignee_agent_id from issues p join privacy_ancestors c on p.id = c.privacy_parent_issue_id and p.company_id = c.company_id where p.visibility = 'private' or exists (select 1 from projects pp where pp.id = p.project_id and pp.company_id = p.company_id and pp.visibility = 'private') ) select 1 from privacy_ancestors n where ${implicit} or ${grant} or ${projectMember} or not exists ( select 1 from privacy_ancestors restricted left join projects private_project on private_project.id = restricted.project_id and private_project.company_id = restricted.company_id where restricted.visibility = 'private' or private_project.visibility = 'private' ) )`; } function issuePrivacyCondition(actor: AuthorizationActor): SQL { const principalCondition = issuePrivacyPrincipalCondition(actor); if (actor.type !== "agent" || !actor.onBehalfOfUserId) return principalCondition; const userId = actor.onBehalfOfUserId; // A shared agent's grants cannot amplify the user whose run is making the call. return sql`(${principalCondition} and ${issuePrivacyPrincipalCondition({ type: "board", userId })} and exists (select 1 from ${companyMemberships} m where m.company_id = ${issues.companyId} and m.principal_type = 'user' and m.principal_id = ${userId} and m.status = 'active'))`; } export async function canActorReadIssuePrivacy(db: Db | DbTransaction, actor: AuthorizationActor, issue: IssuePrivacyRow) { return db.select({ id: issues.id }).from(issues) .where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId), issuePrivacyCondition(actor))) .limit(1).then(rows => rows.length > 0); } /** Filter every externally reachable task query with the same privacy predicate. */ export async function issueReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise> { if (issuePrivacyMode() !== "enforce") return sql`true`; if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql`true`; if (actor.type === "board" && !actor.ignoreInstanceAdmin && actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles) .where(and(eq(instanceUserRoles.userId, actor.userId ?? ""), eq(instanceUserRoles.role, "instance_admin"))) .limit(1).then(rows => rows.length > 0)) return sql`true`; return issuePrivacyCondition(actor); } /** SQL equivalent used for project list/search pushdown. */ export async function projectReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise> { if (issuePrivacyMode() !== "enforce") return sql`true`; if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql`true`; if ( actor.type === "board" && !actor.ignoreInstanceAdmin && actor.source !== "cloud_tenant" && Boolean(await db .select({ id: instanceUserRoles.id }) .from(instanceUserRoles) .where(and( eq(instanceUserRoles.userId, actor.userId ?? ""), eq(instanceUserRoles.role, "instance_admin"), )) .limit(1) .then((rows) => rows[0] ?? null)) ) return sql`true`; return projectPrivacyCondition(actor); } function responsibleUserSnapshotTtlMs() { const raw = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_CACHE_TTL_MS?.trim(); if (!raw) return 5_000; const parsed = Number(raw); return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5_000; } export function responsibleUserAuthzShadowMode() { const mode = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE?.trim().toLowerCase(); const shadow = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW?.trim().toLowerCase(); return mode === "shadow" || shadow === "1" || shadow === "true" || shadow === "yes"; } function activeActorMembership( memberships: Array<{ companyId: string; membershipRole?: string | null; status?: string }> | null | undefined, companyId: string, ) { return memberships?.find((membership) => membership.companyId === companyId && membership.status === "active") ?? null; } function activeResponsibleUserCanAuthorizeIssueAction( action: AuthorizationAction, membership: ResponsibleUserSnapshot["activeMembership"], ) { return Boolean( membership && membership.status === "active" && membership.membershipRole !== "viewer" && (action === "issue:comment" || action === "issue:mutate") ); } function activeResponsibleUserCanAuthorizeAgentGrantedSkillChange( action: AuthorizationAction, membership: ResponsibleUserSnapshot["activeMembership"], agentDecision: AuthorizationDecision, actorAgentId: string | null | undefined, ) { return Boolean( action === "skill_config:update" && membership && membership.status === "active" && membership.membershipRole !== "viewer" && agentDecision.allowed && (agentDecision.reason === "allow_direct_change" || agentDecision.reason === "allow_consented_change") && agentDecision.grant?.principalType === "agent" && agentDecision.grant.principalId === actorAgentId && (agentDecision.grant.permissionKey === "skills:create" || agentDecision.grant.permissionKey === "skills:suggest-changes"), ); } function scopeBoolean(scope: Record | null | undefined, key: string) { return scope?.[key] === true; } export function authorizationDeniedDetails(decision: AuthorizationDecision) { return { ...(decision.code ? { code: decision.code } : {}), reason: decision.reason, }; } type DbTransaction = Parameters[0]>[0]; export function authorizationService(db: Db | DbTransaction) { async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; if ( await db .select({ id: instanceUserRoles.id }) .from(instanceUserRoles) .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) .then((rows) => rows[0] ?? null) ) { return true; } return false; } async function getActiveMembership( companyId: string, principalType: PrincipalType, principalId: string, ) { return db .select() .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, principalType), eq(companyMemberships.principalId, principalId), eq(companyMemberships.status, "active"), ), ) .then((rows) => rows[0] ?? null); } async function loadResponsibleUserSnapshot(companyId: string, userId: string): Promise { const [user, membership] = await Promise.all([ db .select({ id: authUsers.id }) .from(authUsers) .where(eq(authUsers.id, userId)) .then((rows) => rows[0] ?? null), db .select({ companyId: companyMemberships.companyId, membershipRole: companyMemberships.membershipRole, status: companyMemberships.status, }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"), ), ) .then((rows) => rows[0] ?? null), ]); return { userId, companyId, userExists: Boolean(user), activeMembership: user ? membership : null, }; } function getResponsibleUserSnapshot(input: { actor: AuthorizationActor; companyId: string; userId: string; }): Promise { const actorWithMemo = input.actor as ResponsibleUserActorWithMemo; const key = `${input.companyId}:${input.userId}`; actorWithMemo.__responsibleUserSnapshotMemo ??= new Map(); const requestMemo = actorWithMemo.__responsibleUserSnapshotMemo.get(key); if (requestMemo) return requestMemo; const actorMembership = input.actor.onBehalfOfUserId === input.userId ? activeActorMembership(input.actor.onBehalfOfMemberships, input.companyId) : null; if (actorMembership) { const promise = Promise.resolve({ userId: input.userId, companyId: input.companyId, userExists: true, activeMembership: actorMembership, }); actorWithMemo.__responsibleUserSnapshotMemo.set(key, promise); return promise; } const promise = loadResponsibleUserSnapshot(input.companyId, input.userId); actorWithMemo.__responsibleUserSnapshotMemo.set(key, promise); void promise.catch(() => { if (actorWithMemo.__responsibleUserSnapshotMemo?.get(key) === promise) { actorWithMemo.__responsibleUserSnapshotMemo.delete(key); } }); return promise; } async function findGrant( companyId: string, principalType: PrincipalType, principalId: string, permissionKey: PermissionKey, ) { return db .select() .from(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, principalType), eq(principalPermissionGrants.principalId, principalId), eq(principalPermissionGrants.permissionKey, permissionKey), ), ) .then((rows) => rows[0] ?? null); } async function decidePrincipalGrant(input: { companyId: string; principalType: PrincipalType; principalId: string; action: AuthorizationAction; permissionKey: PermissionKey; scope?: Record | null; }): Promise { const membership = await getActiveMembership(input.companyId, input.principalType, input.principalId); if (!membership) { return deny({ action: input.action, reason: "deny_missing_membership", explanation: `${input.principalType} principal ${input.principalId} is not an active member of company ${input.companyId}.`, }); } const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey); if (!grant) { if ( input.principalType === "user" && input.permissionKey.startsWith("tools:") && (membership.membershipRole === "owner" || membership.membershipRole === "admin") && grantsForHumanRole(normalizeHumanRole(membership.membershipRole, "operator")) .some((defaultGrant) => defaultGrant.permissionKey === input.permissionKey) ) { return allow({ action: input.action, reason: "allow_role_default", explanation: `Allowed by the ${membership.membershipRole ?? "operator"} membership role.`, }); } return deny({ action: input.action, reason: "deny_missing_grant", explanation: `Missing permission: ${input.permissionKey}.`, }); } if ( !(await scopeAllows(db, input.companyId, grant.scope, input.scope, { requireStructuredScope: input.permissionKey === "tasks:assign_scope", })) ) { return deny({ action: input.action, reason: "deny_scope", explanation: `Permission ${input.permissionKey} does not cover the requested scope.`, grant: { principalType: input.principalType, principalId: input.principalId, permissionKey: input.permissionKey, scope: grant.scope ?? null, }, }); } return allow({ action: input.action, reason: "allow_explicit_grant", explanation: `Allowed by explicit grant ${input.permissionKey}.`, grant: { principalType: input.principalType, principalId: input.principalId, permissionKey: input.permissionKey, scope: grant.scope ?? null, }, }); } async function loadAgent(agentId: string): Promise { return db .select({ id: agents.id, companyId: agents.companyId, role: agents.role, status: agents.status, reportsTo: agents.reportsTo, permissions: agents.permissions, }) .from(agents) .where(eq(agents.id, agentId)) .then((rows) => rows[0] ?? null); } async function loadProject(projectId: string): Promise { return db .select({ id: projects.id, companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy, }) .from(projects) .where(eq(projects.id, projectId)) .then((rows) => rows[0] ?? null); } async function loadIssue(issueId: string): Promise { return db .select({ id: issues.id, companyId: issues.companyId, projectId: issues.projectId, parentId: issues.parentId, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, checkoutRunId: issues.checkoutRunId, status: issues.status, executionPolicy: issues.executionPolicy, originKind: issues.originKind, originId: issues.originId, }) .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); } async function loadRunPolicy(runId: string | null | undefined, companyId: string, agentId: string) { if (!runId) return null; const row = await db .select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, contextSnapshot: heartbeatRuns.contextSnapshot, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); if (!row || row.companyId !== companyId || row.agentId !== agentId) return null; const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null; return isPlainRecord(context?.executionPolicy) ? { companyId: row.companyId, executionPolicy: context.executionPolicy } : null; } async function loadRunIssueId(runId: string | null | undefined, companyId: string, agentId: string) { if (!runId) return null; const row = await db .select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, contextSnapshot: heartbeatRuns.contextSnapshot, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); if (!row || row.companyId !== companyId || row.agentId !== agentId) return null; const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null; const issueId = typeof context?.issueId === "string" ? context.issueId.trim() : typeof context?.taskId === "string" ? context.taskId.trim() : ""; return issueId || null; } async function isDirectParentReportTarget(input: { actor: AuthorizationActor; actorAgentId: string; companyId: string; resource: AuthorizationResource; }) { if (input.resource.type !== "issue" || !input.resource.issueId) return false; const runIssueId = await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgentId); if (!runIssueId || runIssueId === input.resource.issueId) return false; const runIssue = await loadIssue(runIssueId); return Boolean( runIssue && runIssue.companyId === input.companyId && runIssue.assigneeAgentId === input.actorAgentId && runIssue.checkoutRunId === input.actor.runId && runIssue.parentId === input.resource.issueId, ); } async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) { const row = await db .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) .then((rows) => rows[0] ?? null); return readPolicyObject(row?.executionWorkspacePolicy, "authorizationPolicy"); } async function loadIssueAuthorizationPolicy(companyId: string, issueId: string) { const row = await db .select({ executionPolicy: issues.executionPolicy }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) .then((rows) => rows[0] ?? null); return readPolicyObject(row?.executionPolicy, "authorizationPolicy"); } async function loadResourceContext(resource: AuthorizationResource) { const issue = resource.type === "issue" && resource.issueId ? await loadIssue(resource.issueId) : null; const projectId = resource.type === "issue" ? issue?.projectId ?? resource.projectId ?? null : resource.type === "project" ? resource.projectId ?? null : null; const project = projectId ? await loadProject(projectId) : null; return { issue, project }; } async function resolveActorTrust(input: { actorAgent: AgentAuthorizationRow; actor: AuthorizationActor; companyId: string; resource: AuthorizationResource; }): Promise { const { issue, project } = await loadResourceContext(input.resource); const run = await loadRunPolicy(input.actor.runId, input.companyId, input.actorAgent.id); const resolution = resolveCoreTrustPreset({ companyId: input.companyId, agent: input.actorAgent, project, issue, run, }); if (!input.actor.runId || resolution.kind !== "low_trust_review") return resolution; return withHumanDirectedWork(db, resolution, { companyId: input.companyId, agentId: input.actorAgent.id, runId: input.actor.runId, }); } async function issueIdIsDescendantOf(issueId: string, rootIssueId: string, companyId: string) { const rows = await db.execute(sql` WITH RECURSIVE ancestors(id, parent_id, depth) AS ( SELECT id, parent_id, 0 FROM issues WHERE company_id = ${companyId} AND id = ${issueId} UNION ALL SELECT parent.id, parent.parent_id, ancestors.depth + 1 FROM issues parent JOIN ancestors ON parent.id = ancestors.parent_id WHERE parent.company_id = ${companyId} AND ancestors.depth < ${LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH - 1} ) SELECT EXISTS(SELECT 1 FROM ancestors WHERE id = ${rootIssueId}) AS is_descendant `); const first = Array.isArray(rows) ? rows[0] : null; return Boolean( first && typeof first === "object" && (first as Record).is_descendant === true, ); } async function issueResourceWithinLowTrustBoundary( boundary: LowTrustBoundaryWithCompany, resource: Extract, ) { const issue = resource.issueId ? await loadIssue(resource.issueId) : null; const candidate = { companyId: resource.companyId, id: issue?.id ?? resource.issueId ?? null, projectId: issue?.projectId ?? resource.projectId ?? null, }; if (isIssueWithinLowTrustBoundary(boundary, candidate)) return true; if (candidate.id && boundary.rootIssueId) { return issueIdIsDescendantOf(candidate.id, boundary.rootIssueId, boundary.companyId); } // Only an explicit root scope includes descendants. A parent in a permitted // project (or an exact issue-id scope) cannot authorize a child elsewhere. if (!resource.parentIssueId || !boundary.rootIssueId) return false; const parent = await loadIssue(resource.parentIssueId); if (!parent || parent.companyId !== boundary.companyId) return false; // Root ancestry permits decomposition, not selection of an unrelated // project. Projectless children remain possible for projectless roots. if (candidate.projectId && !await projectWithinLowTrustBoundary(boundary, candidate.projectId)) return false; return parent.id === boundary.rootIssueId || issueIdIsDescendantOf(parent.id, boundary.rootIssueId, boundary.companyId); } async function projectWithinLowTrustBoundary( boundary: LowTrustBoundaryWithCompany, projectId: string | null | undefined, ) { if (!projectId) return false; if (boundary.projectIds?.includes(projectId)) return true; if (!boundary.rootIssueId) return false; const rootIssue = await loadIssue(boundary.rootIssueId); return rootIssue?.companyId === boundary.companyId && rootIssue.projectId === projectId; } function agentWithinLowTrustBoundary( boundary: LowTrustBoundaryWithCompany, actorAgentId: string, targetAgentId: string | null | undefined, ) { if (!targetAgentId) return false; return targetAgentId === actorAgentId || Boolean(boundary.allowedAgentIds?.includes(targetAgentId)); } async function decideLowTrustAccess(input: { actorAgentId: string; actor: AuthorizationActor; action: AuthorizationAction; resource: AuthorizationResource; resolution: TrustPresetResolution; directParentReportTarget: boolean; }): Promise { if (input.resolution.kind === "standard") return null; if (input.resolution.kind === "denied") { return deny({ action: input.action, reason: "deny_policy_restricted", explanation: input.resolution.detail, }); } const boundary = input.resolution.boundary; const lowTrustDeny = (explanation: string) => deny({ action: input.action, reason: "deny_low_trust_boundary", explanation, }); const lowTrustAllow = (explanation: string) => allow({ action: input.action, reason: "allow_low_trust_boundary", explanation, }); if (input.action === "agent_instructions:update") { if (input.resource.type === "agent" && input.resource.agentId === input.actorAgentId && await hasOwnerChatInstructionAuthority(db, { companyId: boundary.companyId, agentId: input.actorAgentId, runId: input.actor.runId, userId: input.actor.onBehalfOfUserId, })) { // Continue through self-instruction permissions, explicit protected // change restrictions, and the responsible user's current edit access. return null; } return lowTrustDeny( "This low-trust run cannot change persistent agent instructions, including AGENTS.md. Self-edits require a direct message in the authorized user's chat with this agent. Outside-triggered work and subtasks do not inherit that authority; ask the authorized user to request the edit in their chat or apply it directly.", ); } if ( input.action === "company_scope:read" || // Agent creation is a company-wide privileged action. The default-on // canCreateAgents flag must never reach the legacy creator allow when // the effective execution context (agent, project, issue, or run // policy) resolves to low trust. input.action === "agents:create" || input.action === "decision_queue:manage" || input.action === "decision_queue:read" || input.action === "decision_triage:manage" || input.action === "agent_config:read" || input.action === "agent_config:update" || input.action === "skill_config:update" || input.action === "inbox:manage" || input.action === "runtime:manage" || input.action === "secrets:read" || input.action === "secrets:propose" ) { return lowTrustDeny( `${LOW_TRUST_REVIEW_PRESET} agents cannot use company-wide or privileged ${input.action} APIs by default.`, ); } if (input.action === "agent:read" || input.action === "agent:wake") { if (input.resource.type !== "agent") { return lowTrustDeny("Low-trust agent action is missing an agent resource."); } return agentWithinLowTrustBoundary(boundary, input.actorAgentId, input.resource.agentId) ? lowTrustAllow("Allowed inside the low-trust agent boundary.") : lowTrustDeny("Agent is outside this low-trust boundary."); } if (input.action === "project:read") { const projectId = input.resource.type === "issue" ? input.resource.projectId : input.resource.type === "project" ? input.resource.projectId : null; return await projectWithinLowTrustBoundary(boundary, projectId) ? lowTrustAllow("Allowed inside the low-trust project boundary.") : lowTrustDeny("Project is outside this low-trust boundary."); } if (input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate") { if (input.resource.type !== "issue") { return lowTrustDeny("Low-trust issue access is missing an issue resource."); } if (input.resolution.humanDirectedIssueId && input.resource.companyId === boundary.companyId && input.resource.issueId === input.resolution.humanDirectedIssueId) { return lowTrustAllow("Allowed on the exact task directed by an authenticated human."); } if (input.action === "issue:comment" && input.directParentReportTarget) { if ( input.resource.issueId && await agentHasMentionGrantOnIssue({ action: input.action, companyId: boundary.companyId, issueId: input.resource.issueId, issueAssigneeAgentId: input.resource.assigneeAgentId ?? null, actorAgentId: input.actorAgentId, }) ) { return allowIssueMentionGrant(input.action); } return lowTrustDeny("Direct-parent report comments are disabled for low-trust review runs."); } if (await issueResourceWithinLowTrustBoundary(boundary, input.resource)) { return lowTrustAllow("Allowed inside the low-trust issue boundary."); } if ( input.action !== "issue:mutate" && input.resource.issueId && await agentHasMentionGrantOnIssue({ action: input.action, companyId: boundary.companyId, issueId: input.resource.issueId, issueAssigneeAgentId: input.resource.assigneeAgentId ?? null, actorAgentId: input.actorAgentId, }) ) { return allowIssueMentionGrant(input.action); } return lowTrustDeny("Issue is outside this low-trust boundary."); } if (input.action === "tasks:assign") { if (input.resource.type !== "issue") { return lowTrustDeny("Low-trust task assignment is missing an issue resource."); } if (!(await issueResourceWithinLowTrustBoundary(boundary, input.resource))) { return lowTrustDeny("Task target is outside this low-trust boundary."); } if (input.resource.assigneeUserId) { return lowTrustDeny("Low-trust agents cannot assign work to board users."); } if ( input.resource.assigneeAgentId && !agentWithinLowTrustBoundary(boundary, input.actorAgentId, input.resource.assigneeAgentId) ) { return lowTrustDeny("Assignee agent is outside this low-trust boundary."); } return null; } return null; } function taskBridgeScopeIds( scope: TaskBridgeAgentKeyScope, singularKey: "projectId" | "parentIssueId", pluralKey: "projectIds" | "parentIssueIds", ) { return [ ...(typeof scope[singularKey] === "string" ? [scope[singularKey]] : []), ...(Array.isArray(scope[pluralKey]) ? scope[pluralKey] : []), ].filter((value): value is string => typeof value === "string" && value.length > 0); } async function parentIssueMatchesTaskBridgeBoundary( parentIssueId: string | null | undefined, companyId: string, allowedParentIssueIds: string[], ) { if (!parentIssueId || allowedParentIssueIds.length === 0) return false; if (allowedParentIssueIds.includes(parentIssueId)) return true; for (const rootIssueId of allowedParentIssueIds) { if (await issueIdIsDescendantOf(parentIssueId, rootIssueId, companyId)) return true; } return false; } async function issueMatchesTaskBridgeCreateBoundary( scope: TaskBridgeAgentKeyScope, resource: Extract, ) { const allowedProjectIds = taskBridgeScopeIds(scope, "projectId", "projectIds"); const allowedParentIssueIds = taskBridgeScopeIds(scope, "parentIssueId", "parentIssueIds"); if (resource.projectId && allowedProjectIds.includes(resource.projectId)) return true; if (await parentIssueMatchesTaskBridgeBoundary(resource.parentIssueId, resource.companyId, allowedParentIssueIds)) { return true; } if (resource.parentIssueId && allowedProjectIds.length > 0) { const parent = await loadIssue(resource.parentIssueId); if (parent?.companyId === resource.companyId && parent.projectId && allowedProjectIds.includes(parent.projectId)) { return true; } } return false; } async function issueMatchesTaskBridgeWriteBoundary(input: { actorAgentId: string; keyId: string; resource: Extract; }) { const issue = input.resource.issueId ? await loadIssue(input.resource.issueId) : null; const assigneeAgentId = issue?.assigneeAgentId ?? input.resource.assigneeAgentId ?? null; if (assigneeAgentId === input.actorAgentId) return true; const originKind = issue?.originKind ?? input.resource.originKind ?? null; const originId = issue?.originId ?? input.resource.originId ?? null; return originKind === "task_bridge" && originId === input.keyId; } async function decideTaskBridgeAccess(input: { actorAgentId: string; action: AuthorizationAction; resource: AuthorizationResource; scope: TaskBridgeAgentKeyScope; keyId: string; }): Promise { const denyBridge = (explanation: string) => deny({ action: input.action, reason: "deny_scope", explanation, }); const allowBridge = (explanation: string) => allow({ action: input.action, reason: "allow_explicit_grant", explanation, }); if ( input.action === "company_scope:read" || input.action === "decision_queue:manage" || input.action === "decision_queue:read" || input.action === "decision_triage:manage" || input.action === "agent:read" || input.action === "agent:wake" || input.action === "project:read" || input.action === "runtime:manage" || input.action === "secrets:read" || input.action === "secrets:propose" ) { return denyBridge("Task bridge keys cannot use company-wide, peer-agent, project, runtime, or secret APIs."); } if (input.action === "tasks:assign") { if (input.resource.type !== "issue") { return denyBridge("Task bridge assignment requires an issue resource."); } if (!(await issueMatchesTaskBridgeCreateBoundary(input.scope, input.resource))) { return denyBridge("Task bridge key is outside its approved parent or project boundary."); } if (input.resource.assigneeUserId) { return denyBridge("Task bridge keys cannot assign work to board users."); } const allowedAssigneeAgentIds = input.scope.allowedAssigneeAgentIds ?? []; if ( input.resource.assigneeAgentId && input.resource.assigneeAgentId !== input.actorAgentId && !allowedAssigneeAgentIds.includes(input.resource.assigneeAgentId) ) { return denyBridge("Task bridge key cannot assign work to that agent."); } return allowBridge("Allowed by task bridge create boundary."); } if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") { if (input.resource.type !== "issue") { return denyBridge("Task bridge issue access requires an issue resource."); } return await issueMatchesTaskBridgeWriteBoundary({ actorAgentId: input.actorAgentId, keyId: input.keyId, resource: input.resource, }) ? allowBridge("Allowed for bridge-created or assigned issue.") : denyBridge("Task bridge key can only access assigned or bridge-created issues."); } return denyBridge("Task bridge key cannot use this API action."); } function decideSkillTestAccess(input: { action: AuthorizationAction; resource: AuthorizationResource; scope: SkillTestAgentKeyScope; }): AuthorizationDecision | null { const denySkillTest = (explanation: string) => deny({ action: input.action, reason: "deny_scope", explanation, }); const allowSkillTest = (explanation: string) => allow({ action: input.action, reason: "allow_explicit_grant", explanation, }); if ( input.action === "company_scope:read" || input.action === "decision_queue:manage" || input.action === "decision_queue:read" || input.action === "decision_triage:manage" || input.action === "agent:read" || input.action === "agent:wake" || input.action === "project:read" || input.action === "runtime:manage" || input.action === "secrets:read" || input.action === "secrets:propose" || input.action === "tasks:assign" ) { return denySkillTest("Skill-test run tokens cannot use company-wide, peer-agent, project, runtime, secret, or task-create APIs."); } if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") { if (input.resource.type !== "issue") { return denySkillTest("Skill-test issue access requires an issue resource."); } return input.resource.issueId === input.scope.issueId ? allowSkillTest("Allowed for the scoped skill-test issue.") : denySkillTest("Skill-test run token can only access its own harness issue."); } return denySkillTest("Skill-test run token cannot use this API action."); } async function assignmentTargetIsInCompany(resource: AuthorizationResource) { if (resource.type !== "issue") return true; if (resource.assigneeAgentId) { const target = await loadAgent(resource.assigneeAgentId); return Boolean( target && target.companyId === resource.companyId && isSimpleAssignableAgentStatus(target.status), ); } if (resource.assigneeUserId) { return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId)); } return true; } async function assignmentPolicyEffect(resource: AuthorizationResource): Promise { if (resource.type !== "issue") return { kind: "none" }; const checks: Array> = []; if (resource.assigneeAgentId) { checks.push( loadAgent(resource.assigneeAgentId).then((agent) => evaluateAuthorizationPolicyForAssignment( readPolicyObject(agent?.permissions, "authorizationPolicy"), "Target agent", ), ), ); } if (resource.projectId) { checks.push( loadProjectAuthorizationPolicy(resource.companyId, resource.projectId).then((policy) => evaluateAuthorizationPolicyForAssignment(policy, "Target project"), ), ); } if (resource.issueId) { checks.push( loadIssueAuthorizationPolicy(resource.companyId, resource.issueId).then((policy) => evaluateAuthorizationPolicyForAssignment(policy, "Target issue"), ), ); } if (resource.parentIssueId && resource.parentIssueId !== resource.issueId) { checks.push( loadIssueAuthorizationPolicy(resource.companyId, resource.parentIssueId).then((policy) => evaluateAuthorizationPolicyForAssignment(policy, "Parent issue"), ), ); } if (checks.length === 0) return { kind: "none" }; const effects = await Promise.all(checks); return ( effects.find((effect) => effect.kind === "unknown") ?? effects.find((effect) => effect.kind === "blocked") ?? effects.find((effect) => effect.kind === "restricted") ?? { kind: "none" } ); } async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) { return isAgentInSubtree(db, companyId, managerAgentId, assigneeAgentId); } function commentAuthorCanGrantIssueMention(input: { mentionedAgentId: string; issueAssigneeAgentId: string | null; authorAgentId: string | null; authorUserId: string | null; activeAuthorUserIds: Set; }) { if (input.authorAgentId) { if (input.authorAgentId === input.mentionedAgentId) return false; return input.issueAssigneeAgentId === input.authorAgentId; } if (input.authorUserId) { return input.activeAuthorUserIds.has(input.authorUserId); } return false; } async function agentHasMentionGrantOnIssue(input: { action: AuthorizationAction; companyId: string; issueId: string; issueAssigneeAgentId: string | null; actorAgentId: string; }) { const rows = await db .select({ id: issueComments.id, body: issueComments.body, authorAgentId: issueComments.authorAgentId, authorUserId: issueComments.authorUserId, }) .from(issueComments) .where(and( eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId), isNull(issueComments.deletedAt), sql`${issueComments.body} LIKE ${"%agent://" + input.actorAgentId + "%"}`, )); const mentionRows = rows.filter((row) => extractAgentMentionIds(row.body).includes(input.actorAgentId)); const authorUserIds = [...new Set(mentionRows.flatMap((row) => row.authorUserId ? [row.authorUserId] : []))]; const activeAuthorUserIds = new Set( authorUserIds.length === 0 ? [] : await db .select({ principalId: companyMemberships.principalId }) .from(companyMemberships) .where(and( eq(companyMemberships.companyId, input.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), inArray(companyMemberships.principalId, authorUserIds), )) .then((memberships) => memberships.map((membership) => membership.principalId)), ); for (const row of mentionRows) { const authorCanGrant = commentAuthorCanGrantIssueMention({ mentionedAgentId: input.actorAgentId, issueAssigneeAgentId: input.issueAssigneeAgentId, authorAgentId: row.authorAgentId, authorUserId: row.authorUserId, activeAuthorUserIds, }); if (authorCanGrant) { logger.info({ actorAgentId: input.actorAgentId, issueId: input.issueId, companyId: input.companyId, commentId: row.id, grantedAction: input.action, grant: "issue_mention_comment", }, "authorized issue mention-scoped comment grant"); return true; } } return false; } function allowIssueMentionGrant(action: AuthorizationAction): AuthorizationDecision { return allow({ action, reason: "allow_issue_mention_grant", explanation: "Allowed by a mention-scoped issue comment grant.", }); } async function decideBase(input: { actor: AuthorizationActor; action: AuthorizationAction; resource: AuthorizationResource; scope?: Record | null; }): Promise { const permissionKey = permissionForAction(input.action); const companyId = companyIdForResource(input.resource); /** * Shared default-open decision for issue write-influence channels. * * Keep visibility structurally upstream of every standard-trust write so * future visibility scoping can be implemented in issue:read without * recreating per-action scope checks. The responsible-user ceiling remains * in decide(), after this base decision. */ async function decideVisibleIssueWrite(): Promise { let visibilityResource: AuthorizationResource = input.resource; // New-child assignment decisions identify the issue being influenced by // parentIssueId. Resolve that parent into the same resource shape used by // issue:read so child-create and assign share the visibility hook. if ( input.resource.type === "issue" && !input.resource.issueId && input.resource.parentIssueId ) { const parent = await loadIssue(input.resource.parentIssueId); if (!parent || parent.companyId !== companyId) { return deny({ action: input.action, reason: "deny_company_boundary", explanation: "The issue write target is not visible in this company.", }); } visibilityResource = { type: "issue", companyId: parent.companyId, issueId: parent.id, projectId: parent.projectId, parentIssueId: parent.parentId, assigneeAgentId: parent.assigneeAgentId, assigneeUserId: parent.assigneeUserId, originKind: parent.originKind, originId: parent.originId, status: parent.status, }; } const visibilityDecision = visibilityResource.type === "issue" && visibilityResource.issueId ? await decideBase({ actor: input.actor, action: "issue:read", resource: visibilityResource, scope: input.scope, }) : await decideBase({ actor: input.actor, action: "company_scope:read", resource: { type: "company", companyId }, scope: input.scope, }); if (!visibilityDecision.allowed) { return { ...visibilityDecision, action: input.action, explanation: `Issue write denied because the target is not visible: ${visibilityDecision.explanation}`, }; } return allow({ action: input.action, reason: "allow_visible_issue_write", explanation: "Allowed by the shared default-open visible-issue write rule.", }); } async function decideWithTaskAssignmentGrants( principalType: PrincipalType, principalId: string, ): Promise { const broadDecision = await decidePrincipalGrant({ companyId, principalType, principalId, action: input.action, permissionKey: "tasks:assign", scope: input.scope, }); if (broadDecision.allowed || broadDecision.reason === "deny_missing_membership") return broadDecision; const scopedDecision = await decidePrincipalGrant({ companyId, principalType, principalId, action: input.action, permissionKey: "tasks:assign_scope", scope: input.scope, }); if (scopedDecision.allowed || broadDecision.reason === "deny_missing_grant") return scopedDecision; return broadDecision; } async function decideWithAgentConfigReadGrant( principalType: PrincipalType, principalId: string, ): Promise { const configureDecision = await decidePrincipalGrant({ companyId, principalType, principalId, action: input.action, permissionKey: "agents:configure", scope: input.scope, }); if (configureDecision.allowed || configureDecision.reason === "deny_missing_membership") { return configureDecision; } const suggestDecision = await decidePrincipalGrant({ companyId, principalType, principalId, action: input.action, permissionKey: "agents:suggest-changes", scope: input.scope, }); if (suggestDecision.allowed || suggestDecision.reason === "deny_missing_grant") { return suggestDecision; } return configureDecision; } async function decideWithProtectedChangeGrants( principalType: PrincipalType, principalId: string, keys: { direct: PermissionKey; suggest: PermissionKey }, ): Promise { const directDecision = await decidePrincipalGrant({ companyId, principalType, principalId, action: input.action, permissionKey: keys.direct, scope: input.scope, }); if (directDecision.allowed) { return allow({ action: input.action, reason: "allow_direct_change", explanation: `Allowed by direct change permission ${keys.direct}.`, grant: directDecision.grant, }); } if (directDecision.reason === "deny_missing_membership") return directDecision; const suggestDecision = await decidePrincipalGrant({ companyId, principalType, principalId, action: input.action, permissionKey: keys.suggest, scope: input.scope, }); if (suggestDecision.allowed) { if (scopeBoolean(input.scope, "consentedChange")) { return allow({ action: input.action, reason: "allow_consented_change", explanation: `Allowed by suggest permission ${keys.suggest} after accepted change consent.`, grant: suggestDecision.grant, }); } return deny({ action: input.action, reason: "deny_missing_consent", explanation: `Permission ${keys.suggest} requires accepted change consent before applying this mutation.`, grant: suggestDecision.grant, }); } if (suggestDecision.reason === "deny_missing_membership") return suggestDecision; if (directDecision.reason === "deny_scope") return directDecision; if (suggestDecision.reason === "deny_scope") return suggestDecision; return deny({ action: input.action, reason: "deny_no_grant", explanation: `Missing permission: ${keys.direct} or ${keys.suggest}.`, }); } async function denyForAssignmentPolicyIfNeeded( policyEffect: AssignmentPolicyEffect, ): Promise { if (policyEffect.kind === "none" || policyEffect.kind === "restricted") return null; return deny({ action: input.action, reason: "deny_policy_restricted", explanation: policyEffect.explanation, }); } function denyRestrictedAssignmentPolicy(policyEffect: AssignmentPolicyEffect): AuthorizationDecision { return deny({ action: input.action, reason: "deny_policy_restricted", explanation: policyEffect.kind === "restricted" ? policyEffect.explanation : "Restrictive authorization policy blocks simple company-wide task assignment.", }); } if (input.actor.type === "none") { return deny({ action: input.action, reason: "deny_unauthenticated", explanation: "Authentication required.", }); } if (input.actor.type === "board") { let taskAssignmentPolicyEffect: AssignmentPolicyEffect | null = null; if (input.actor.source === "local_implicit") { return allow({ action: input.action, reason: "allow_local_board", explanation: "Allowed because the actor is the local implicit board.", }); } // MCP grants always use their explicitly consented company membership, // even when the consenting person is an instance administrator. // A cloud_tenant actor's computed `isInstanceAdmin` flag is trusted: it // can only be set by the attested trusted-header resolver (stack owner + // `enableOwnerInstanceAdmin`). The `instance_user_roles` DB lookup stays // excluded for cloud_tenant actors, so a stale or hand-inserted // instance_admin row left behind by deployments that ran the // pre-hardening cloud_tenant path still elevates nothing. if ( !input.actor.ignoreInstanceAdmin && input.actor.source !== "mcp_oauth" && (input.actor.isInstanceAdmin || (input.actor.source !== "cloud_tenant" && await isInstanceAdmin(input.actor.userId))) ) { return allow({ action: input.action, reason: "allow_instance_admin", explanation: "Allowed because the actor is an instance admin.", }); } // What instance-admin elevation used to give cloud tenant users is // replaced by company-scoped visibility: an active membership in the // resource company grants the same read surface a same-company agent // gets, and non-viewer members may mutate issues inside their company. // Cross-company access stays denied. if (input.actor.source === "cloud_tenant" && input.actor.userId) { const membership = await getActiveMembership(companyId, "user", input.actor.userId); if (membership) { if ( input.action === "agent:read" || input.action === "company_scope:read" || input.action === "decision_queue:read" || input.action === "issue:read" || input.action === "project:read" ) { return allow({ action: input.action, reason: "allow_company_member", explanation: "Allowed by active cloud tenant company membership.", }); } if ( ( input.action === "issue:comment" || input.action === "issue:mutate" || input.action === "decision_queue:manage" || input.action === "decision_triage:manage" ) && membership.membershipRole !== "viewer" ) { return allow({ action: input.action, reason: "allow_company_member", explanation: "Allowed by active cloud tenant company membership.", }); } } } if (!input.actor.userId) { return deny({ action: input.action, reason: "deny_unauthenticated", explanation: "Board user id is required.", }); } if (input.action === "tasks:assign") { if (!(await assignmentTargetIsInCompany(input.resource))) { return deny({ action: input.action, reason: "deny_company_boundary", explanation: "Task assignment target agent is not active in the target company.", }); } const policyEffect = await assignmentPolicyEffect(input.resource); taskAssignmentPolicyEffect = policyEffect; const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect); if (policyDeny) return policyDeny; const membership = await getActiveMembership(companyId, "user", input.actor.userId); if (policyEffect.kind === "none" && membership && membership.membershipRole !== "viewer") { return allow({ action: input.action, reason: "allow_simple_company_member", explanation: "Allowed by simple mode company-wide task assignment default.", }); } } if (input.action === "agent_config:read") { return decideWithAgentConfigReadGrant("user", input.actor.userId); } if (input.action === "agent_config:update" || input.action === "agent_instructions:update") { return decideWithProtectedChangeGrants("user", input.actor.userId, { direct: "agents:configure", suggest: "agents:suggest-changes", }); } if (input.action === "skill_config:update") { return decideWithProtectedChangeGrants("user", input.actor.userId, { direct: "skills:create", suggest: "skills:suggest-changes", }); } if (!permissionKey) { if (input.action === "issue:comment" || input.action === "issue:mutate") { if ( input.resource.type !== "issue" || !input.resource.issueId || typeof input.resource.status !== "string" || input.resource.assigneeAgentId === undefined || input.resource.assigneeUserId === undefined ) { return deny({ action: input.action, reason: "deny_unsupported_action", explanation: `No board permission mapping exists for ${input.action}.`, }); } const membership = await getActiveMembership(companyId, "user", input.actor.userId); if (membership && membership.membershipRole !== "viewer") { return allow({ action: input.action, reason: "allow_simple_company_member", explanation: "Allowed by standard same-company board membership issue mutation.", }); } if (membership) { return deny({ action: input.action, reason: "deny_missing_grant", explanation: `Viewer membership does not grant ${input.action}.`, }); } return deny({ action: input.action, reason: "deny_missing_membership", explanation: `user principal ${input.actor.userId} is not an active member of company ${companyId}.`, }); } if ( input.action === "agent:read" || input.action === "agent:wake" || input.action === "company_scope:read" || input.action === "decision_queue:manage" || input.action === "decision_queue:read" || input.action === "decision_triage:manage" || input.action === "issue:read" || input.action === "project:read" || input.action === "runtime:manage" || input.action === "secrets:read" || input.action === "secrets:propose" ) { const membership = await getActiveMembership(companyId, "user", input.actor.userId); // Mirroring the tasks:assign carve-out above, viewers keep the // read-only visibility actions but not the privileged ones. const requiresNonViewer = input.action === "agent:wake" || input.action === "runtime:manage" || input.action === "secrets:read" || input.action === "decision_queue:manage" || input.action === "decision_triage:manage"; if (membership && (!requiresNonViewer || membership.membershipRole !== "viewer")) { return allow({ action: input.action, reason: "allow_simple_company_member", explanation: "Allowed by standard same-company board membership visibility.", }); } if (membership) { return deny({ action: input.action, reason: "deny_missing_grant", explanation: `Viewer membership does not grant ${input.action}.`, }); } return deny({ action: input.action, reason: "deny_missing_membership", explanation: `user principal ${input.actor.userId} is not an active member of company ${companyId}.`, }); } return deny({ action: input.action, reason: "deny_unsupported_action", explanation: `No board permission mapping exists for ${input.action}.`, }); } if (input.action === "tasks:assign") { const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId); if (grantDecision.allowed) return grantDecision; const policyEffect = taskAssignmentPolicyEffect ?? await assignmentPolicyEffect(input.resource); if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect); return grantDecision; } return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId, action: input.action, permissionKey, scope: input.scope, }); } const actorAgentId = input.actor.agentId ?? null; if (!actorAgentId) { return deny({ action: input.action, reason: "deny_unauthenticated", explanation: "Agent authentication required.", }); } if (input.actor.companyId !== companyId) { return deny({ action: input.action, reason: "deny_company_boundary", explanation: "Agent key cannot access another company.", }); } const actorAgent = await loadAgent(actorAgentId); if (!actorAgent || actorAgent.companyId !== companyId) { return deny({ action: input.action, reason: "deny_company_boundary", explanation: "Actor agent was not found in the target company.", }); } if (input.actor.keyScope?.kind === "skill_test") { const skillTestDecision = decideSkillTestAccess({ action: input.action, resource: input.resource, scope: input.actor.keyScope, }); if (skillTestDecision) return skillTestDecision; } if (input.actor.keyScope?.kind === "task_bridge") { const keyId = input.actor.keyId ?? null; if (!keyId) { return deny({ action: input.action, reason: "deny_scope", explanation: "Task bridge key context is missing.", }); } const taskBridgeDecision = await decideTaskBridgeAccess({ actorAgentId, action: input.action, resource: input.resource, scope: input.actor.keyScope, keyId, }); if (taskBridgeDecision) return taskBridgeDecision; } const trustResolution = await resolveActorTrust({ actorAgent, actor: input.actor, companyId, resource: input.resource, }); const directParentReportTarget = input.action === "issue:comment" && await isDirectParentReportTarget({ actor: input.actor, actorAgentId, companyId, resource: input.resource, }); const lowTrustDecision = await decideLowTrustAccess({ actorAgentId, actor: input.actor, action: input.action, resource: input.resource, resolution: trustResolution, directParentReportTarget, }); if (lowTrustDecision) { if (!lowTrustDecision.allowed) return lowTrustDecision; if ( input.action === "agent:read" || input.action === "agent:wake" || input.action === "company_scope:read" || input.action === "decision_queue:read" || input.action === "issue:comment" || input.action === "issue:read" || input.action === "project:read" || input.action === "runtime:manage" || input.action === "secrets:read" || input.action === "secrets:propose" ) { return lowTrustDecision; } } const visibleIssueWriteDecision = trustResolution.kind === "standard" && (input.action === "issue:comment" || input.action === "issue:mutate") ? await decideVisibleIssueWrite() : null; if (visibleIssueWriteDecision && !visibleIssueWriteDecision.allowed) { return visibleIssueWriteDecision; } if ( trustResolution.kind === "standard" && input.action === "issue:comment" && directParentReportTarget ) { return allow({ action: input.action, reason: "allow_direct_parent_report", explanation: "Allowed because the target is the current run issue's direct parent under the standard trust preset.", }); } if (input.action === "inbox:manage") { if (!isSimpleAssignableAgentStatus(actorAgent.status)) { return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Actor agent is not active in the target company.", }); } const responsibleUserId = input.actor.onBehalfOfUserId?.trim() || null; const explicitTargetUserId = typeof input.scope?.userId === "string" ? input.scope.userId.trim() || null : null; const targetUserId = explicitTargetUserId ?? responsibleUserId; if (!targetUserId) { return deny({ action: input.action, reason: "inbox_target_user_unresolved", explanation: "Inbox target user could not be resolved from the request or responsible-user context.", }); } const targetSnapshot = await getResponsibleUserSnapshot({ actor: input.actor, companyId, userId: targetUserId, }); if (!targetSnapshot.userExists || !targetSnapshot.activeMembership) { return deny({ action: input.action, reason: "deny_missing_membership", explanation: `Inbox target user ${targetUserId} is not an active member of company ${companyId}.`, }); } const policy = await db .select({ mode: userInboxAgentPolicies.mode, allowedAgentIds: userInboxAgentPolicies.allowedAgentIds, }) .from(userInboxAgentPolicies) .where( and( eq(userInboxAgentPolicies.companyId, companyId), eq(userInboxAgentPolicies.userId, targetUserId), ), ) .then((rows) => rows[0] ?? null); if (targetUserId !== responsibleUserId) { // A scoped grant remains an administrative override, including over a // disabled user policy. Otherwise, a materialized target-user policy is // explicit consent for agents selected in the profile control. The // implicit default-open policy remains responsible-user-only so an // absent row never becomes a company-wide cross-user grant. const grant = await findGrant(companyId, "agent", actorAgentId, "inbox:manage"); if (grant && grant.scope?.responsibleUserOnly !== true && (await scopeAllows(db, companyId, grant.scope, { userId: targetUserId }))) { return allow({ action: input.action, reason: "allow_explicit_grant", explanation: "Allowed by explicit grant inbox:manage.", inboxPolicyMode: "grant_override", grant: { principalType: "agent", principalId: actorAgentId, permissionKey: "inbox:manage", scope: grant.scope ?? null, }, }); } if (policy?.mode === "disabled") { return deny({ action: input.action, reason: "inbox_management_disabled", explanation: `Inbox management is disabled for user ${targetUserId}.`, }); } if (policy?.mode === "allowlist" && !policy.allowedAgentIds.includes(actorAgentId)) { return deny({ action: input.action, reason: "inbox_agent_not_allowed", explanation: `Agent ${actorAgentId} is not allowed to manage user ${targetUserId}'s inbox.`, }); } if (policy?.mode === "open" || policy?.mode === "allowlist") { return allow({ action: input.action, reason: "allow_user_inbox_policy", inboxPolicyMode: policy.mode, explanation: policy.mode === "allowlist" ? "Allowed by the target user's inbox agent allowlist." : "Allowed by the target user's saved open inbox policy.", }); } if (grant) { return deny({ action: input.action, reason: "deny_scope", explanation: "Permission inbox:manage does not cover the requested user.", grant: { principalType: "agent", principalId: actorAgentId, permissionKey: "inbox:manage", scope: grant.scope ?? null, }, }); } return deny({ action: input.action, reason: "deny_missing_grant", explanation: "Missing permission: inbox:manage.", }); } if (policy?.mode === "disabled") { return deny({ action: input.action, reason: "inbox_management_disabled", explanation: `Inbox management is disabled for user ${targetUserId}.`, }); } if (policy?.mode === "allowlist" && !policy.allowedAgentIds.includes(actorAgentId)) { return deny({ action: input.action, reason: "inbox_agent_not_allowed", explanation: `Agent ${actorAgentId} is not allowed to manage user ${targetUserId}'s inbox.`, }); } return allow({ action: input.action, reason: "allow_self", inboxPolicyMode: policy?.mode ?? "open", explanation: policy?.mode === "allowlist" ? "Allowed by the responsible user's inbox agent allowlist." : "Allowed by the responsible user's default-open inbox policy.", }); } if ( input.action === "agent:read" || input.action === "company_scope:read" || input.action === "decision_queue:read" || input.action === "issue:read" || input.action === "project:read" || input.action === "runtime:manage" || input.action === "secrets:read" || input.action === "secrets:propose" ) { return allow({ action: input.action, reason: "allow_company_agent", explanation: "Allowed by standard same-company agent visibility.", }); } if (input.action === "decision_queue:manage" || input.action === "decision_triage:manage") { if (!isSimpleAssignableAgentStatus(actorAgent.status)) { return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Actor agent is not active in the target company.", }); } return allow({ action: input.action, reason: "allow_company_agent", explanation: "Allowed for an active standard-scope company agent.", }); } if (input.action === "agent:wake" && input.resource.type === "agent" && input.resource.agentId === actorAgentId) { return allow({ action: input.action, reason: "allow_self", explanation: "Allowed because the actor is waking itself.", }); } if (input.action === "tasks:assign") { if (!isSimpleAssignableAgentStatus(actorAgent.status)) { return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Actor agent is not active for simple mode task assignment.", }); } if (!(await assignmentTargetIsInCompany(input.resource))) { return deny({ action: input.action, reason: "deny_company_boundary", explanation: "Task assignment target agent is not active in the target company.", }); } const policyEffect = await assignmentPolicyEffect(input.resource); const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect); if (policyDeny) return policyDeny; if (policyEffect.kind === "restricted") { const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId); if (grantDecision.allowed) return grantDecision; return denyRestrictedAssignmentPolicy(policyEffect); } if (trustResolution.kind === "standard") return decideVisibleIssueWrite(); return allow({ action: input.action, reason: "allow_simple_company_member", explanation: "Allowed by the existing bounded task assignment rule.", }); } if (input.action === "issue:comment" || input.action === "issue:mutate") { const resource = input.resource.type === "issue" ? input.resource : null; if (resource?.assigneeAgentId === actorAgentId) { return allow({ action: input.action, reason: "allow_self", explanation: "Allowed because the actor owns the assigned issue.", }); } if (!resource?.assigneeAgentId) { return allow({ action: input.action, reason: "allow_company_agent", explanation: "Allowed because the issue has no agent assignee.", }); } if ( input.action === "issue:comment" && resource?.issueId && await agentHasMentionGrantOnIssue({ action: input.action, companyId, issueId: resource.issueId, issueAssigneeAgentId: resource.assigneeAgentId ?? null, actorAgentId, }) ) { return allowIssueMentionGrant(input.action); } if (visibleIssueWriteDecision) return visibleIssueWriteDecision; } if ( input.action === "agent_config:update" && input.resource.type === "agent" && input.resource.agentId === actorAgentId && !scopeBoolean(input.scope, "requiresChangeGrant") ) { return allow({ action: input.action, reason: "allow_self", explanation: "Allowed because the actor is updating its own agent configuration.", }); } if (input.action === "agent_config:read") { if (input.resource.type === "agent" && input.resource.agentId === actorAgentId) { return allow({ action: input.action, reason: "allow_self", explanation: "Allowed because the actor is reading its own agent configuration.", }); } return decideWithAgentConfigReadGrant("agent", actorAgentId); } if (input.action === "agent_instructions:update") { if (!isSimpleAssignableAgentStatus(actorAgent.status) || !input.actor.onBehalfOfUserId) { return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Instruction edits require an active agent and a responsible user." }); } // Explicit configure/suggest restrictions still govern content changes. // Only self edits may fall back to the responsible user's target access. const restricted = await decideWithProtectedChangeGrants("agent", actorAgentId, { direct: "agents:configure", suggest: "agents:suggest-changes", }); if (restricted.reason !== "deny_no_grant" || input.resource.type !== "agent" || input.resource.agentId !== actorAgentId) return restricted; return allow({ action: input.action, reason: "allow_self", explanation: "Own instruction content edit, subject to the responsible user's target edit access." }); } if (input.action === "agent_config:update") { return decideWithProtectedChangeGrants("agent", actorAgentId, { direct: "agents:configure", suggest: "agents:suggest-changes", }); } if (input.action === "skill_config:update") { return decideWithProtectedChangeGrants("agent", actorAgentId, { direct: "skills:create", suggest: "skills:suggest-changes", }); } if (permissionKey) { const grantDecision = await decidePrincipalGrant({ companyId, principalType: "agent", principalId: actorAgentId, action: input.action, permissionKey, scope: input.scope, }); if (grantDecision.allowed) return grantDecision; } if (input.action === "agents:create" && canCreateAgentsLegacy(actorAgent)) { return allow({ action: input.action, reason: "allow_legacy_agent_creator", explanation: "Allowed by legacy agent creator authority.", }); } // Active-checkout management deliberately does not ride on // canCreateAgents: that flag is default-on for standard-trust agents, and // coupling would let any peer write over another agent's checked-out // issue. CEOs, explicit grants, and the manager chain remain the paths. if (input.action === "tasks:manage_active_checkouts" && actorAgent.role === "ceo") { return allow({ action: input.action, reason: "allow_legacy_agent_creator", explanation: "Allowed by legacy agent creator authority.", }); } if ( input.action === "tasks:manage_active_checkouts" && input.resource.type === "issue" && input.resource.assigneeAgentId && await isManagerOf(companyId, actorAgentId, input.resource.assigneeAgentId) ) { return allow({ action: input.action, reason: "allow_manager_chain", explanation: "Allowed because the actor manages the issue assignee in the reporting chain.", }); } return deny({ action: input.action, reason: "deny_missing_grant", explanation: permissionKey ? `Missing permission: ${permissionKey}.` : `No agent permission mapping exists for ${input.action}.`, }); } async function applyResponsibleUserIntersection( input: { actor: AuthorizationActor; action: AuthorizationAction; resource: AuthorizationResource; scope?: Record | null; }, agentDecision: AuthorizationDecision, ): Promise { const responsibleUserId = input.actor.onBehalfOfUserId?.trim(); if ( input.actor.type !== "agent" || input.action === "inbox:manage" || !responsibleUserId || !agentDecision.allowed ) { return agentDecision; } const companyId = companyIdForResource(input.resource); const snapshot = await getResponsibleUserSnapshot({ actor: input.actor, companyId, userId: responsibleUserId, }); const denyCode: AuthorizationDecision["code"] = snapshot.userExists && snapshot.activeMembership ? "RESPONSIBLE_USER_UNAUTHORIZED" : "RESPONSIBLE_USER_UNAVAILABLE"; if ( activeResponsibleUserCanAuthorizeAgentGrantedSkillChange( input.action, snapshot.activeMembership, agentDecision, input.actor.agentId, ) ) { // Skill mutations are governed by the agent's explicit skill-change // grant. The responsible-user intersection still requires an active // non-viewer user, but does not require duplicating that grant on the // responsible user's board account for standard heartbeat JWTs. return agentDecision; } const userDecision = snapshot.userExists && snapshot.activeMembership ? await decideBase({ ...input, actor: { type: "board", userId: responsibleUserId, companyIds: [companyId], memberships: [snapshot.activeMembership], isInstanceAdmin: false, ignoreInstanceAdmin: true, source: "session", }, }) : deny({ action: input.action, reason: "deny_missing_membership", explanation: `Responsible user ${responsibleUserId} is unavailable for company ${companyId}.`, }); if ( !userDecision.allowed && userDecision.reason === "deny_unsupported_action" && activeResponsibleUserCanAuthorizeIssueAction(input.action, snapshot.activeMembership) ) { return agentDecision; } if (userDecision.allowed) return agentDecision; const denied = deny({ action: input.action, reason: userDecision.reason, code: denyCode, explanation: denyCode === "RESPONSIBLE_USER_UNAVAILABLE" ? `Responsible user ${responsibleUserId} is unavailable for company ${companyId}.` : `Responsible user ${responsibleUserId} is not authorized for ${input.action}: ${userDecision.explanation}`, grant: userDecision.grant, }); logger.warn({ authzMode: responsibleUserAuthzShadowMode() ? "shadow" : "enforce", code: denied.code, reason: userDecision.reason, action: input.action, resourceType: input.resource.type, companyId, actorAgentId: input.actor.agentId ?? null, responsibleUserId, }, "responsible-user authorization intersection denied"); return input.action !== "agent_instructions:update" && responsibleUserAuthzShadowMode() ? agentDecision : denied; } async function decide(input: { actor: AuthorizationActor; action: AuthorizationAction; resource: AuthorizationResource; scope?: Record | null; }): Promise { const agentDecision = await decideBase(input); const intersectedDecision = await applyResponsibleUserIntersection(input, agentDecision); if ( input.action === "project:read" && input.resource.type === "project" && input.resource.projectId && intersectedDecision.allowed && intersectedDecision.reason !== "allow_instance_admin" && intersectedDecision.reason !== "allow_local_board" && issuePrivacyMode() !== "off" ) { const project = await db .select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility }) .from(projects) .where(and(eq(projects.id, input.resource.projectId), eq(projects.companyId, input.resource.companyId))) .then((rows) => rows[0] ?? null); if (project && !(await canActorReadProjectPrivacy(db, input.actor, project))) { const denied = deny({ action: input.action, reason: "deny_project_private", explanation: "Project is private and the actor is not an access member.", }); logger.warn({ authzMode: issuePrivacyMode(), action: input.action, companyId: project.companyId, projectId: project.id, actorType: input.actor.type, actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null, actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null, reason: denied.reason, }, "project privacy would deny read"); return issuePrivacyMode() === "shadow" ? intersectedDecision : denied; } } if ( input.action !== "issue:read" || input.resource.type !== "issue" || !input.resource.issueId || !intersectedDecision.allowed || intersectedDecision.reason === "allow_instance_admin" || intersectedDecision.reason === "allow_local_board" || issuePrivacyMode() === "off" ) return intersectedDecision; const issue = await db .select({ id: issues.id, companyId: issues.companyId, visibility: issues.visibility, privacyRootIssueId: issues.privacyRootIssueId, responsibleUserId: issues.responsibleUserId, createdByUserId: issues.createdByUserId, assigneeUserId: issues.assigneeUserId, assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId, }) .from(issues) .where(and(eq(issues.id, input.resource.issueId), eq(issues.companyId, input.resource.companyId))) .then((rows) => rows[0] ?? null); if (!issue || await canActorReadIssuePrivacy(db, input.actor, issue)) return intersectedDecision; const denied = deny({ action: input.action, reason: "deny_issue_private", explanation: "Issue is private and the actor is not an implicit principal or active grantee.", }); logger.warn({ authzMode: issuePrivacyMode(), action: input.action, companyId: issue.companyId, issueId: issue.id, privacyRootIssueId: issue.privacyRootIssueId, actorType: input.actor.type, actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null, actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null, reason: denied.reason, }, "issue privacy would deny read"); return issuePrivacyMode() === "shadow" ? intersectedDecision : denied; } // A candidate filter only: project policies and responsible-user grants are // still evaluated by decide(). Project policies can contribute an additional // root/project scope, so the query must also retain projects with such policy. async function projectDiscoveryCandidateIds(actor: AuthorizationActor, companyId: string): Promise { if (actor.type !== "agent" || !actor.agentId || actor.keyScope) return null; const agent = await loadAgent(actor.agentId); if (!agent || agent.companyId !== companyId) return []; const run = await loadRunPolicy(actor.runId, companyId, agent.id); const resolution = resolveCoreTrustPreset({ companyId, agent, run }); // A project can supply a missing boundary; never prefilter that case. if (resolution.kind !== "low_trust_review") return null; const ids = new Set(resolution.boundary.projectIds ?? []); if (resolution.boundary.rootIssueId) { const root = await loadIssue(resolution.boundary.rootIssueId); if (root?.companyId === companyId && root.projectId) ids.add(root.projectId); } return [...ids]; } return { decide, projectDiscoveryCandidateIds, decidePrincipalGrant, }; } /** A workspace can contain several tasks. Reading files/operations requires * access to every task bound to it, not just the task that originally made it. */ export async function executionWorkspaceReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise> { const issueCondition = await issueReadSqlCondition(db, actor); const projectCondition = await projectReadSqlCondition(db, actor); return sql`( (${executionWorkspaces.projectId} is null or exists ( select 1 from ${projects} where ${projects.id} = ${executionWorkspaces.projectId} and ${projects.companyId} = ${executionWorkspaces.companyId} and ${projectCondition})) and not exists (select 1 from jsonb_object_keys(coalesce(${executionWorkspaces.metadata}->'_issuePrivacySources', '{}'::jsonb)) source(id) where not exists (select 1 from ${issues} where ${issues.id}::text = source.id and ${issues.companyId} = ${executionWorkspaces.companyId} and ${issueCondition})) and not exists (select 1 from ${issues} where ${issues.companyId} = ${executionWorkspaces.companyId} and (${issues.executionWorkspaceId} = ${executionWorkspaces.id} or ${issues.id} = ${executionWorkspaces.sourceIssueId}) and not ${issueCondition}) )`; } /** An approval may contain task-derived payload; every linked task must be readable. */ export async function approvalReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise> { const readable = await issueReadSqlCondition(db, actor); return sql`not exists ( select 1 from (values (${approvals.payload}->>'issueId'), (${approvals.payload}->>'sourceIssueId')) claimed(id) where claimed.id is not null and not exists ( select 1 from ${issues} where ${issues.id}::text = claimed.id and ${issues.companyId} = ${approvals.companyId}) ) and not exists ( select 1 from ${issues} where ${issues.companyId} = ${approvals.companyId} and (${issues.id}::text = ${approvals.payload}->>'issueId' or ${issues.id}::text = ${approvals.payload}->>'sourceIssueId' or exists (select 1 from ${issueApprovals} ia where ia.approval_id = ${approvals.id} and ia.company_id = ${approvals.companyId} and ia.issue_id = ${issues.id})) and not (${readable}) )`; } export async function canActorReadApproval(db: Db | DbTransaction, actor: AuthorizationActor, approvalId: string) { return db.select({ id: approvals.id }).from(approvals) .where(and(eq(approvals.id, approvalId), await approvalReadSqlCondition(db, actor))) .limit(1).then(rows => rows.length > 0); } export async function canActorReadExecutionWorkspace(db: Db, actor: AuthorizationActor, id: string) { return db.select({ id: executionWorkspaces.id }).from(executionWorkspaces) .where(and(eq(executionWorkspaces.id, id), await executionWorkspaceReadSqlCondition(db, actor))) .limit(1).then(rows => rows.length > 0); } /** Recheck the external audience immediately before publishing task-derived content. */ export async function canPublishIssueToChatAudience( tx: Db | DbTransaction, publication: { issueId: string; companyId: string; conversationId: string; endpointId: string }, ): Promise { const [privacyIssue] = await tx.select().from(issues) .where(and(eq(issues.id, publication.issueId), eq(issues.companyId, publication.companyId))); if (!privacyIssue) return false; if (!(await canActorReadIssuePrivacy(tx, { type: "none" }, privacyIssue))) { const [conversation] = await tx.select().from(chatConversations).where(and( eq(chatConversations.id, publication.conversationId), eq(chatConversations.companyId, publication.companyId), eq(chatConversations.endpointId, publication.endpointId))); // Shared channels have no bounded, authenticated recipient set. Private // output may leave only through a DM whose known recipients still qualify. if (!conversation?.isDirectMessage) return false; const recipients = await tx.selectDistinct({ principalId: chatDeliveries.principalId }).from(chatDeliveries) .where(and(eq(chatDeliveries.conversationId, conversation.id), eq(chatDeliveries.companyId, publication.companyId), inArray(chatDeliveries.eventKind, ["message", "mention"]))); if (!recipients.length || recipients.some(recipient => !recipient.principalId)) return false; for (const recipient of recipients) { const [link] = await tx.select().from(chatIdentityLinks).where(and( eq(chatIdentityLinks.companyId, publication.companyId), eq(chatIdentityLinks.endpointId, publication.endpointId), eq(chatIdentityLinks.principalId, recipient.principalId!), eq(chatIdentityLinks.status, "linked"), isNull(chatIdentityLinks.revokedAt))); if (!link?.paperclipUserId) return false; const allowed = await authorizationService(tx).decide({ actor: { type: "board", userId: link.paperclipUserId, source: "session", ignoreInstanceAdmin: true }, action: "issue:read", resource: { type: "issue", companyId: publication.companyId, issueId: publication.issueId } }); if (!allowed.allowed) return false; } } return true; }