Files
PaperClipAI/server/src/services/authorization.ts
T
DottaandPaperclip 6c9b87d092 trace(server): expose repository admission waits (#17040)
## Thinking Path

> - Paperclip manages work performed by AI agents.
> - The controller prepares a task workspace before it starts a provider
turn.
> - Repository admission also runs when a checkout already exists.
> - One observed reuse took 16.877 seconds. Its child spans explained
only 3.556 seconds.
> - The missing detail covered repeated authority checks, catalog work,
and publication waits.
> - This change exposes those operations under the existing execution
attempt.
> - Operators can expand the slow phase before choosing a performance
fix.

## Linked Issues or Issue Description

Refs #16963, #16330, and #16515. The provider-routing work in #17019 has
a different scope.

**What existing behavior does this improve?**

Repository-admission traces for new and reused checkouts.

**Subsystem affected**

server/ orchestration and operator-configured OpenTelemetry tracing.

**Current behavior**

The admission span can contain a long interval without child spans.
Existing project checks, HTTP requests, and checkout lookup do not
explain current-task checks, database awaits, credential refresh, or
publication locks.

**Proposed behavior**

Expose nested spans for current binding and task access, individual
identity reads, catalog construction, credentials, grant transactions,
filesystem validation, Git inspection, and directory-lock acquisition,
work, and release.

**Reason and benefit**

The trace can distinguish repeated checks, remote calls, database
awaits, and directory contention. A database-await span includes driver,
network, pool, and server time. It is not isolated SQL execution time.

**Breaking changes**

None. No API, schema, permission, retry, lock, or repository behavior
changes.

## What Changed

- Add fixed admission phases beneath the existing execution attempt.
Keep the existing project, checkout, and HTTP detail.
- Trace current-task authorization stages only under workspace access.
Unrelated decisions cannot consume that detail budget before admission.
- Trace GitHub catalog reads, credentials, provider metadata, grant row
locking and writes, transaction settlement, and webhook work.
- Separate directory-lock acquisition, the protected operation, and
release without changing the adapter lock contract.
- Document the hierarchy and timing limits. Keep endpoint gating, span
caps, and hashed run correlation. Add no span attributes or exception
text.

## Verification

- Nine new tests passed with real PostgreSQL, real Git, and SDK-exported
spans. They cover directory contention, grant-row blocking, all three
binding checks, reuse without cloning, dirty files, private-task denial,
receipt repair, conflict retry, endpoint-off behavior, and parent IDs.
- Twenty earlier unrelated authorization decisions do not exhaust the
admission detail budget.
- Existing authorization and trust suites: 105 tests passed. Existing
timing/context suites: 26 tests passed. Existing repository-access
tests: 6 tests passed.
- `pnpm -r typecheck` and `pnpm build` passed on commit
`5a986d8c86644dfb7ee39b3b832e2d7c86532598`.
- `pnpm test:run` could not complete locally. The temporary
five-connection cluster returned `sorry, too many clients already` in
chat fixtures, and the run was interrupted. It is not a pass. The
complete standard CI workflow passed on the same commit, including all
12 server shards and all three chat shards.
- Final current-head checks: 54 success, two neutral, two skipped, zero
pending or failing. The [CI
aggregate](https://github.com/paperclipai/paperclip/actions/runs/39522187940/job/118971261973)
passed.
[Greptile](https://github.com/paperclipai/paperclip/pull/17040#issuecomment-6305066520)
and [Paperclip
Review](https://github.com/paperclipai/paperclip/pull/17040#issuecomment-6305017182)
both assessed this exact commit at 5/5 with no actionable findings or
unresolved threads.
- `node scripts/check-module-boundaries.mjs` and `git diff --check`
passed.
- This macOS host exhausted its SysV semaphore quota. Database tests
used a temporary command-scoped initdb preload with five connections,
fewer workers, and a larger lock table. No repository test
infrastructure or shared host settings changed. Initial skipped tests,
focused-fixture failures, and clock-order assertion failures were
corrected and rerun; they are not counted as passes. The complete local
run remained connection-limited and was interrupted.
- Local SDK export proves parentage and privacy. Hosted Honeycomb
ingestion remains unverified until deployment. No deployment occurred.

## Risks

Span recording requires an operator-configured OTLP endpoint. Each fixed
detail name remains capped at 16 occurrences per attempt. Later
operations still execute under their enclosing phase. Timing wrappers
preserve the existing operations and their errors. No migration is
required. The change does not itself reduce admission latency.

## Model Used

OpenAI Codex, based on GPT-6, with tool use, code execution, and
delegated agents. The exact hosted model identifier and context-window
size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 19:50:51 -05:00

2999 lines
117 KiB
TypeScript

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 { canonicalExecutionUuid } from "./execution-identity.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";
import { isRunPhaseActive, measureRunPhase, type ExecutionPhase } from "./execution-preparation-timing.js";
function measureWorkspaceAuthorization<T>(phase: Extract<ExecutionPhase, `authorization.${string}`>, work: () => T | Promise<T>): T | Promise<T> {
return isRunPhaseActive("workspace.access.task_authorization") || isRunPhaseActive("workspace.access.authorization")
? measureRunPhase(phase, work) : work();
}
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;
/** Pinned, server-derived external authorizer; never an agent-selected principal. */
authorizingUserId?: 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"
| "project:write_workspace"
| "runtime:manage"
| "runtime:confirm_owner_stop"
| "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<string, unknown> | null;
};
};
type PrincipalGrantDecision = AuthorizationDecision & {
grant?: NonNullable<AuthorizationDecision["grant"]>;
};
function companyIdForResource(resource: AuthorizationResource) {
return resource.companyId;
}
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
if (action === "project:write_workspace") return "tasks:assign";
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 === "runtime:confirm_owner_stop" ||
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<string, unknown>, 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<string, unknown>, 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function objectIsEmpty(value: Record<string, unknown>) {
return Object.keys(value).length === 0;
}
function readPolicyObject(container: unknown, key: string): Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, AgentHierarchyRow>,
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<string, unknown> | null,
requestedScope: Record<string, unknown> | 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, "allowed">): AuthorizationDecision {
return { ...input, allowed: true };
}
function deny(input: Omit<AuthorizationDecision, "allowed">): 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<string, Promise<ResponsibleUserSnapshot>>;
};
const responsibleUserSnapshotCache = new Map<
string,
{ expiresAt: number; promise: Promise<ResponsibleUserSnapshot> }
>();
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<boolean> {
const principal = issuePrivacyPrincipal(actor);
return sql<boolean>`(${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<boolean> {
const own = projectPrivacyPrincipalCondition(actor);
const userIds = actor.type === "agent"
? [...new Set([actor.onBehalfOfUserId, actor.authorizingUserId].filter((id): id is string => !!id))] : [];
return userIds.reduce((condition, userId) => sql<boolean>`(${condition}
and ${projectPrivacyPrincipalCondition({ type: "board", userId, ignoreInstanceAdmin: true })}
and exists (select 1 from ${companyMemberships} m where m.company_id = ${projects.companyId}
and m.principal_type = 'user' and m.principal_id = ${userId} and m.status = 'active'))`, own);
}
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<boolean> {
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<boolean>`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<boolean> {
const own = issuePrivacyPrincipalCondition(actor);
const userIds = actor.type === "agent"
? [...new Set([actor.onBehalfOfUserId, actor.authorizingUserId].filter((id): id is string => !!id))] : [];
// Shared agent grants cannot amplify either the task owner or external authorizer.
return userIds.reduce((condition, userId) => sql<boolean>`(${condition}
and ${issuePrivacyPrincipalCondition({ type: "board", userId, ignoreInstanceAdmin: true })}
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'))`, own);
}
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<SQL<boolean>> {
if (actor.authorizingUserId) return issuePrivacyCondition(actor);
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`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<boolean>`true`;
return issuePrivacyCondition(actor);
}
/** SQL equivalent used for project list/search pushdown. */
export async function projectReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
if (actor.authorizingUserId) return projectPrivacyCondition(actor);
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`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<boolean>`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<string, unknown> | 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<Parameters<Db["transaction"]>[0]>[0];
type ReadAuthorizationSnapshot = {
issues: Map<string, IssueAuthorizationRow & IssuePrivacyRow & { readable: boolean }>;
projects: Map<string, ProjectAuthorizationRow & ProjectPrivacyRow & { readable: boolean }>;
runs: Map<string, { companyId: string; agentId: string; contextSnapshot: Record<string, unknown> } | null>;
memo: Map<string, Promise<unknown>>;
};
type ReadAuthorizationResource = Extract<AuthorizationResource, { type: "issue" | "project" | "company" }>;
export function authorizationService(db: Db | DbTransaction) {
return createAuthorizationService(db);
}
function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadAuthorizationSnapshot) {
function batchRead<T>(key: string, read: () => Promise<T>): Promise<T> {
if (!readSnapshot) return read();
let result = readSnapshot.memo.get(key);
if (!result) { result = read(); readSnapshot.memo.set(key, result); }
return result as Promise<T>;
}
async function decideReads(actor: AuthorizationActor, resources: ReadAuthorizationResource[]): Promise<AuthorizationDecision[]> {
return (await decideReadsForActors([actor], resources))[0];
}
async function decideReadsForActors(actors: AuthorizationActor[], resources: ReadAuthorizationResource[]): Promise<AuthorizationDecision[][]> {
if (!actors.length) return [];
const memo = new Map<string, Promise<unknown>>();
const runs: ReadAuthorizationSnapshot["runs"] = new Map();
const snapshots: ReadAuthorizationSnapshot[] = actors.map(() => ({ issues: new Map(), projects: new Map(), runs, memo }));
const batchActors: ResponsibleUserActorWithMemo[] = actors.map(actor => ({ ...actor, __responsibleUserSnapshotMemo: undefined }));
// Only canonical IDs take this fast path. Other inputs keep the ordinary
// lookup behavior, including PostgreSQL's handling of invalid UUIDs.
const runIds = resources.length ? [...new Set(batchActors.flatMap(actor => actor.type === "agent" && actor.runId
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(actor.runId) ? [actor.runId] : []))] : [];
for (let offset = 0; offset < runIds.length; offset += 500) {
const ids = runIds.slice(offset, offset + 500);
for (const id of ids) runs.set(id, null);
const rows = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
}).from(heartbeatRuns).where(inArray(heartbeatRuns.id, ids));
for (const row of rows) runs.set(row.id, row);
}
const privacyKey = (actor: AuthorizationActor) => JSON.stringify([actor.type, actor.agentId, actor.userId, actor.onBehalfOfUserId]);
const privacyActors = [...new Map(batchActors.map(actor => [privacyKey(actor), actor])).values()];
const privacyIndexes = batchActors.map(actor => privacyActors.findIndex(peer => privacyKey(peer) === privacyKey(actor)));
const issueIds = [...new Set(resources.flatMap(resource => resource.type === "issue" && resource.issueId ? [resource.issueId] : []))];
const projectIds = new Set(resources.flatMap(resource => resource.type === "project" && resource.projectId ? [resource.projectId]
: resource.type === "issue" && resource.projectId ? [resource.projectId] : []));
for (let offset = 0; offset < issueIds.length; offset += 500) {
const rows = await 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,
visibility: issues.visibility, privacyRootIssueId: issues.privacyRootIssueId,
responsibleUserId: issues.responsibleUserId, createdByUserId: issues.createdByUserId,
readable: sql<boolean[]>`ARRAY[${sql.join(privacyActors.map(actor => issuePrivacyCondition(actor)), sql`, `)}]`,
}).from(issues).where(inArray(issues.id, issueIds.slice(offset, offset + 500)));
for (const row of rows) {
for (let index = 0; index < snapshots.length; index++) snapshots[index].issues.set(row.id, { ...row, readable: row.readable[privacyIndexes[index]] });
if (row.projectId) projectIds.add(row.projectId);
}
}
const ids = [...projectIds];
for (let offset = 0; offset < ids.length; offset += 500) {
const rows = await db.select({ id: projects.id, companyId: projects.companyId,
executionWorkspacePolicy: projects.executionWorkspacePolicy, visibility: projects.visibility,
readable: sql<boolean[]>`ARRAY[${sql.join(privacyActors.map(actor => projectPrivacyCondition(actor)), sql`, `)}]`,
}).from(projects).where(inArray(projects.id, ids.slice(offset, offset + 500)));
for (const row of rows) for (let index = 0; index < snapshots.length; index++) {
snapshots[index].projects.set(row.id, { ...row, readable: row.readable[privacyIndexes[index]] });
}
}
const results: AuthorizationDecision[][] = [];
for (let index = 0; index < batchActors.length; index++) {
const batch = createAuthorizationService(db, snapshots[index]);
const decisions: AuthorizationDecision[] = [];
for (const resource of resources) decisions.push(await batch.decide({ actor: batchActors[index], resource,
action: resource.type === "issue" ? "issue:read" : resource.type === "project" ? "project:read" : "company_scope:read" }));
results.push(decisions);
}
return results;
}
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
if (!userId) return false;
return batchRead(`admin:${userId}`, async () => {
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 batchRead(`membership:${companyId}:${principalType}:${principalId}`, async () => {
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<ResponsibleUserSnapshot> {
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<ResponsibleUserSnapshot> {
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 = batchRead(`responsible:${input.companyId}:${input.userId}`,
() => 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<string, unknown> | null;
}): Promise<PrincipalGrantDecision> {
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<AgentAuthorizationRow | null> {
return batchRead(`agent:${agentId}`, async () => {
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<ProjectAuthorizationRow | null> {
if (readSnapshot?.projects.has(projectId)) return readSnapshot.projects.get(projectId)!;
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<IssueAuthorizationRow | null> {
if (readSnapshot?.issues.has(issueId)) return readSnapshot.issues.get(issueId)!;
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) {
return batchRead(`run:${companyId}:${agentId}:${runId}`, async () => {
if (!runId) return null;
const row = readSnapshot?.runs.has(runId) ? readSnapshot.runs.get(runId) : await db
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
})
.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: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId')`,
})
.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<TrustPresetResolution> {
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<string, unknown>).is_descendant === true,
);
}
async function issueResourceWithinLowTrustBoundary(
boundary: LowTrustBoundaryWithCompany,
resource: Extract<AuthorizationResource, { type: "issue" }>,
) {
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<AuthorizationDecision | null> {
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" || input.action === "project:write_workspace") {
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<AuthorizationResource, { type: "issue" }>,
) {
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<AuthorizationResource, { type: "issue" }>;
}) {
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<AuthorizationDecision | null> {
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 === "project:write_workspace" ||
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 === "project:write_workspace" ||
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<AssignmentPolicyEffect> {
if (resource.type !== "issue") return { kind: "none" };
const checks: Array<Promise<AssignmentPolicyEffect>> = [];
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<string>;
}) {
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<string, unknown> | null;
}): Promise<AuthorizationDecision> {
const permissionKey = permissionForAction(input.action);
const companyId = companyIdForResource(input.resource);
if (input.action === "project:write_workspace" && (input.resource.type !== "issue" || !input.resource.projectId)) {
return deny({ action: input.action, reason: "deny_unsupported_action", explanation: "Workspace writes require a source project and task context." });
}
const taskAssignmentAction = input.action === "tasks:assign" || input.action === "project:write_workspace";
const requestedAssignmentPolicy = () => input.action === "project:write_workspace" && input.resource.type === "issue"
? loadProjectAuthorizationPolicy(companyId, input.resource.projectId!).then(policy => evaluateAuthorizationPolicyForAssignment(policy, "Target project"))
: assignmentPolicyEffect(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<AuthorizationDecision> {
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<AuthorizationDecision> {
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<AuthorizationDecision> {
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<AuthorizationDecision> {
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<AuthorizationDecision | null> {
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.action === "runtime:confirm_owner_stop" && input.actor.type !== "board") {
return deny({ action: input.action, reason: "deny_unsupported_action",
explanation: "Only a deployment administrator can confirm a workspace controller has stopped." });
}
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.",
});
}
if (input.action === "runtime:confirm_owner_stop") {
const membership = await getActiveMembership(companyId, "user", input.actor.userId ?? "");
if (membership?.membershipRole === "owner" || membership?.membershipRole === "admin") {
return allow({ action: input.action, reason: "allow_role_default",
explanation: "Allowed by the active company owner or administrator role." });
}
return deny({ action: input.action, reason: "deny_missing_grant",
explanation: "Only a company owner, company administrator, or instance administrator can confirm a workspace controller has stopped." });
}
// 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 (taskAssignmentAction) {
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 requestedAssignmentPolicy();
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 (taskAssignmentAction) {
const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId);
if (grantDecision.allowed) return grantDecision;
const policyEffect = taskAssignmentPolicyEffect ?? await requestedAssignmentPolicy();
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 (taskAssignmentAction) {
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 requestedAssignmentPolicy();
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<string, unknown> | null;
},
agentDecision: AuthorizationDecision,
): Promise<AuthorizationDecision> {
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 applyAuthorizingUserIntersection(
input: {
actor: AuthorizationActor;
action: AuthorizationAction;
resource: AuthorizationResource;
scope?: Record<string, unknown> | null;
},
agentDecision: AuthorizationDecision,
): Promise<AuthorizationDecision> {
const authorizingUserId = input.actor.authorizingUserId?.trim();
if (input.actor.type !== "agent" || !authorizingUserId || !agentDecision.allowed) return agentDecision;
const companyId = companyIdForResource(input.resource);
// External authority always uses current membership, without the request's
// responsible-user memo or rollout exceptions for agent-owned grants.
const snapshot = await loadResponsibleUserSnapshot(companyId, authorizingUserId);
const userDecision = snapshot.userExists && snapshot.activeMembership
? await decideBase({
...input,
actor: { type: "board", userId: authorizingUserId, ignoreInstanceAdmin: true, source: "session" },
})
: deny({
action: input.action,
reason: "deny_missing_membership",
explanation: "The authorizing user is not an active company member.",
});
if (userDecision.allowed) return agentDecision;
return deny({
action: input.action,
reason: userDecision.reason,
explanation: `Authorizing user is not authorized for ${input.action}: ${userDecision.explanation}`,
grant: userDecision.grant,
});
}
async function decide(input: {
actor: AuthorizationActor;
action: AuthorizationAction;
resource: AuthorizationResource;
scope?: Record<string, unknown> | null;
}): Promise<AuthorizationDecision> {
const agentDecision = await measureWorkspaceAuthorization("authorization.base", () => decideBase(input));
const responsibleDecision = await measureWorkspaceAuthorization("authorization.responsible_user", () => applyResponsibleUserIntersection(input, agentDecision));
const intersectedDecision = await measureWorkspaceAuthorization("authorization.authorizing_user", () => applyAuthorizingUserIntersection(input, responsibleDecision));
if (
input.action === "project:read"
&& input.resource.type === "project"
&& input.resource.projectId
&& intersectedDecision.allowed
&& intersectedDecision.reason !== "allow_instance_admin"
&& intersectedDecision.reason !== "allow_local_board"
&& (input.actor.authorizingUserId || issuePrivacyMode() !== "off")
) {
const resource = input.resource;
const privacyDecision = await measureWorkspaceAuthorization("authorization.project_privacy", async () => {
const retainedProject = readSnapshot?.projects.get(resource.projectId!);
const project = retainedProject?.companyId === resource.companyId ? retainedProject : await db
.select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility })
.from(projects)
.where(and(eq(projects.id, resource.projectId!), eq(projects.companyId, resource.companyId)))
.then((rows) => rows[0] ?? null);
if (project && !(retainedProject?.companyId === resource.companyId
? retainedProject.readable : 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 !input.actor.authorizingUserId && issuePrivacyMode() === "shadow" ? intersectedDecision : denied;
}
return null;
});
if (privacyDecision) return privacyDecision;
}
if (
input.action !== "issue:read"
|| input.resource.type !== "issue"
|| !input.resource.issueId
|| !intersectedDecision.allowed
|| intersectedDecision.reason === "allow_instance_admin"
|| intersectedDecision.reason === "allow_local_board"
|| (!input.actor.authorizingUserId && issuePrivacyMode() === "off")
) return intersectedDecision;
const resource = input.resource;
return measureWorkspaceAuthorization("authorization.task_privacy", async () => {
const retainedIssue = readSnapshot?.issues.get(resource.issueId!);
const issue = retainedIssue?.companyId === resource.companyId ? retainedIssue : 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, resource.issueId!), eq(issues.companyId, resource.companyId)))
.then((rows) => rows[0] ?? null);
if (!issue || (retainedIssue?.companyId === resource.companyId
? retainedIssue.readable : 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 !input.actor.authorizingUserId && 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<string[] | null> {
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,
decideReads,
decideReadsForActors,
projectDiscoveryCandidateIds,
decidePrincipalGrant,
};
}
/** Protect disclosure of workspace records and their task-derived app metadata.
* Filesystem execution and task-bound file roots use current task access instead. */
export async function executionWorkspaceReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
const issueCondition = await issueReadSqlCondition(db, actor);
const projectCondition = await projectReadSqlCondition(db, actor);
return sql<boolean>`(
(${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} = ${canonicalExecutionUuid(sql`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<SQL<boolean>> {
const readable = await issueReadSqlCondition(db, actor);
return sql<boolean>`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<boolean> {
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;
}