Files
PaperClipAI/server/src/services/authorization.ts
T
DottaandPaperclip e34abee670 feat(mcp): connect assistants to a team with user OAuth (#14846)
## Thinking Path

> - Paperclip gives teams durable tasks, agent execution, budgets, and
approvals.
> - People also use assistants in Codex, Claude, and other MCP clients.
> - Those assistants need a scoped connection that preserves the
person’s permissions and attribution.
> - Delegating a task must not turn the assistant into the assigned
agent.
> - This PR adds opt-in user OAuth, ten first-party tools, browser
consent, and workflow packages.
> - Paid product evals verify the resulting tasks, documents,
attribution, retries, and access boundaries.
> - The team keeps working after the assistant conversation ends.

## Linked Issues or Issue Description

**Problem or motivation**

A person cannot connect an external assistant to an existing team
through browser consent and safely delegate durable work as themselves.

**Proposed solution**

Expose an opt-in `/mcp/paperclip` endpoint with individually described
first-party operations. Bind every connection to a person, client,
company, resource, and scopes. Reuse domain authorization and
scheduling. Package shared team-review, delegation, and follow-up
workflows for OpenAI/Codex and Claude.

**Alternatives considered**

Related PRs #9393 and #12549 cover earlier remote MCP and board-operator
approaches. This change uses user OAuth and a bounded public catalog. It
does not expose a generic executor, operator administration, static
shared board credentials, or external agent execution. Registry listing
work in #9851 is a separate distribution step.

**Roadmap alignment**

This maintainer-requested implementation extends the governed MCP
gateway, activity attribution, durable work products, and hosted
deployment direction in `ROADMAP.md`. It implements the first release of
the saved design plan; external agent participation and granted
third-party tools remain later releases.

## What Changed

- Add MCP 2.0 discovery and task status/comment/document Events on the
same authenticated endpoint. Persist subscriptions and delivery
receipts, verify HTTPS callbacks, sign Standard Webhooks, encrypt
callback material, recheck permissions/Cloud membership, and bound
retries/expiry. Older MCP clients keep their existing tools.
- Add discovery, dynamic client registration, S256 PKCE, resource
validation, rotating refresh tokens, revocation, and company consent.
Store credentials as hashes and recheck membership at execution.
- Add tools for connection identity, agents/projects, task
search/read/create, human comments, documents/deliverables, and
pending-approval links. Preserve current domain permissions and
scheduling.
- Add durable mutation receipts across reconnects. Matching retries
replay results; uncertain outcomes keep the same request ID and require
inspection.
- Add consent and connection-management pages, OAuth log redaction,
shared plugin workflows, and separate OpenAI/Codex and Claude package
outputs.
- Add eight paid Product E2E cases across three models, independent
durable-state grading, usage evidence, cleanup, and report integration.
Add task-document guidance and regenerate the runner capability
inventories.
- Add migrations 0301 and 0302, the dated implementation plan, result
notes, and direct-client setup instructions in `doc/public-mcp.md`.

## Verification

- Merge integration `e180b1948`: resolved conflicts with current master,
preserved both eval registries, regenerated capability catalogs, and
regenerated migrations as 0301/0302 while keeping the original
replay-safe SQL byte-identical. Local migration safety/snapshot tests
(26), MCP/OAuth tests (38), redaction/OpenAPI tests (71), and eval
catalog/grading tests (198) pass. Token and capability gates pass. Full
recursive typecheck passed. Fresh Greptile review is 5/5 with no
unresolved findings. CI is green on this exact head (55 successes, two
intentional skips, one neutral result): one unchanged Cursor sandbox
test timed out at 10 seconds, then passed locally in 856 ms. A single
retry of that failed shard and the aggregate workflow passed. Merge
remains blocked on the repository code-owner approval rule.

Earlier checks passed at `6aa0962d4fb715f2190bb7bb22efacab2e58495d`: 55
successes, two intentional skips and one neutral result. [The earlier CI
run](https://github.com/paperclipai/paperclip/actions/runs/36901592350)
includes all test shards, browser tests, typecheck, build and canary dry
run. Greptile was 5/5 on that commit with no unresolved review threads.
GitHub still requires code-owner review under the repository merge
rules; passing checks do not bypass that approval. Paid source
fingerprints remain separate below and in the dated result note.

- Paid Events qualification passes **3/3**: GPT-5.4 Mini, Claude Haiku
4.5 and Claude Sonnet 4.6. Each uses a real public HTTPS callback,
signature verification and report retrieval in a fresh conversation. A
final Mini regression passes after the quota/status fixes. All evidence
validates. Bounded tunnel startup retries occur before provider calls
and remain visible; failed earlier attempts retain their original
grades.
- The earlier complete seven-case matrix passes **21/21**, with a
separate **3/3** delegation regression. Two preceding matrices also
passed 21/21 each. A complete 24-cell matrix including Events has not
been run. [The dated
results](doc/plans/2026-10-01-public-mcp-paid-eval-results.md) retain
exact source fingerprints, failures, model IDs and partial costs.
- Node 24: repository-wide `pnpm -r typecheck` and `pnpm build` pass
after merging master. Server typecheck passes after the final
quota/status changes. Eval typecheck and all 892 eval-support tests
pass.
- All 33 real MCP/OAuth tests pass. The preceding combined MCP,
redaction, private-address and DNS-rebinding run passed 129 tests; two
later MCP regressions cover quota reuse and unchanged-status
suppression. All 28 adjacent issue-tree/stale-lock route tests pass. CI
then found a null checkout result in the existing concurrent-workspace
path; logging now uses optional status access. All 12 closed-workspace
tests and all 33 MCP tests pass after that correction. The exact-start
event calibration exposed a timestamp gap; scanning now includes the
subscription start, with all 33 MCP tests and server typecheck passing.
These two narrow corrections follow the paid regression.
- A real Core → Cloud → Core authority round trip passes OAuth, MCP 2.0
subscription/delivery, current membership loss, unsubscribe, legacy SDK
tools, refresh and revocation. Its callback transport is a fixture with
independent HMAC verification. The paid Events campaigns separately
prove public HTTPS delivery.
- Earlier component qualification passed UI 7,117 tests, CLI 502, shared
832, skills catalog 20, database 160 and OpenAPI 10. Token gates, module
boundaries, migration order and plugin regeneration passed. CI covers
general/serialized suites, eight browser shards, runner checks,
typecheck, build and canary dry run.
- **Local full-suite limitation:** the earlier monolithic run was not
clean. It encountered overlapping schema rebuilding, Mac database
shared-memory limits and isolated CLI/fixture failures. Targeted reruns
passed. The existing >32 MiB Git filename stress test still hit its
300-second Mac timeout. The additional serialized sweep stopped after 62
passing suites once CI passed. Original failures and partial logs
remain; this PR does not claim a wholly green local monolithic run.
- Local Codex CLI and Claude Code OAuth login and MCP SDK
interoperability were verified. Public-store installation, actual
ChatGPT Work Cloud Events UI, staging HTTPS client behavior and hosted
newcomer provisioning remain release gates.

Enablement is moving to **Settings → Experimental → Assistant
connections (MCP)** in the stacked follow-up
[#14933](https://github.com/paperclipai/paperclip/pull/14933). Merge
both for the intended setup experience. This foundation branch alone
still uses `PAPERCLIP_PUBLIC_MCP_ENABLED=true`. After deployment, set
`PAPERCLIP_PUBLIC_URL` to the authenticated instance's HTTPS origin, and
connect to `/mcp/paperclip`. Select a team and allow writes in browser
consent. Configure an available agent and budget, then delegate and
retrieve results later. For Events, rescan the deployed plugin catalog
in ChatGPT Work Cloud; the host supplies its webhook credentials when
the user asks to watch a task. See [the setup
runbook](doc/public-mcp.md).

## Risks

- Events are at-least-once and may arrive out of order. No replay cursor
is advertised. Clients must refresh finite subscriptions, read current
state and avoid comment feedback loops. Callback material uses the
instance secrets master key; hosted subscriptions require the updated
Cloud broker and are bounded to five minutes/the access proof expiry.
- ChatGPT Work Cloud/dot event UI, plugin rescan and a hosted staging
subscription remain deployment gates. Local signed-webhook and paid
model evidence does not claim those surfaces have been exercised.
- Disabled by default. Merging adds schema and opt-in code; it does not
deploy a public endpoint, publish a store listing, create a team, or
start paid agents.
- Migrations 0301 and 0302 are additive and idempotent. Their SQL is
unchanged from the earlier preview numbers, so hash-aware upgrade
reconciliation preserves prior staging applications. Normal instance
upgrades must apply it before enabling MCP.
- Task creation and comments can schedule paid agent work. Consent and
tool descriptions disclose that effect. Revocation blocks future calls
but does not undo delegated work.
- Public deployments need edge rate limits and credential-safe logging.
Internal dispatch is restricted to the closed catalog and carries a
request-local verified actor.
- Hosted onboarding requires the companion Cloud broker, encryption-key
configuration, and tenant rollout. Self-hosted direct connections can
use this PR alone.
- Store acceptance and agent-mode participation are not claimed.
Checked-in plugin endpoints are development defaults; rebuild packages
for a real deployment before installation.

## Model Used

OpenAI GPT-6 in Codex, with reasoning, tool use, and code execution. A
more specific serving version and context-window size were not exposed
by the session. Paid eval models: `gpt-5.4-mini-2026-03-17`,
`claude-haiku-4-5-20251001`, and `claude-sonnet-4-6`.

## 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 (targeted/component checks;
full local-run limitations are recorded above)
- [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-06 11:48:53 -05:00

2493 lines
89 KiB
TypeScript

import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { withHumanDirectedWork } from "./human-directed-work.js";
import { hasOwnerChatInstructionAuthority } from "./owner-chat-instruction-authority.js";
import {
agents,
authUsers,
companyMemberships,
heartbeatRuns,
instanceUserRoles,
issueComments,
issues,
principalPermissionGrants,
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 "./agent-permissions.js";
import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js";
export type AuthorizationActor =
{
type: "board" | "agent" | "none";
userId?: string | null;
sessionId?: string | null;
companyIds?: string[];
memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>;
onBehalfOfMemberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>;
isInstanceAdmin?: boolean;
ignoreInstanceAdmin?: boolean;
agentId?: string | null;
companyId?: string | null;
keyId?: string | null;
keyScope?: AgentApiKeyScope | null;
runId?: string | null;
onBehalfOfUserId?: string | null;
source?:
| "local_implicit"
| "session"
| "board_key"
| "agent_key"
| "agent_jwt"
| "mcp_oauth"
| "cloud_tenant"
| "cloud_control"
| "none";
};
export type AuthorizationAction =
| PermissionKey
| "agent_config:read"
| "agent_config:update"
| "agent_instructions:update"
| "skill_config:update"
| "agent:read"
| "agent:wake"
| "company_scope:read"
| "decision_queue:manage"
| "decision_queue:read"
| "decision_triage:manage"
| "issue:comment"
| "issue:mutate"
| "issue:read"
| "project:read"
| "runtime:manage"
| "secrets:read"
| "secrets:propose";
export type AuthorizationResource =
| { type: "company"; companyId: string }
| { type: "agent"; companyId: string; agentId?: string | null }
| { type: "project"; companyId: string; projectId?: string | null }
| {
type: "issue";
companyId: string;
issueId?: string | null;
projectId?: string | null;
parentIssueId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
originKind?: string | null;
originId?: string | null;
status?: string | null;
};
export type AuthorizationDecision = {
allowed: boolean;
action: AuthorizationAction;
explanation: string;
inboxPolicyMode?: InboxAgentPolicyMode | "grant_override";
code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE";
reason:
| "allow_low_trust_boundary"
| "allow_local_board"
| "allow_instance_admin"
| "allow_explicit_grant"
| "allow_role_default"
| "allow_user_inbox_policy"
| "allow_direct_change"
| "allow_consented_change"
| "allow_legacy_agent_creator"
| "allow_issue_mention_grant"
| "allow_direct_parent_report"
| "allow_visible_issue_write"
| "allow_self"
| "allow_company_agent"
| "allow_company_member"
| "allow_simple_company_member"
| "allow_manager_chain"
| "inbox_target_user_unresolved"
| "inbox_management_disabled"
| "inbox_agent_not_allowed"
| "deny_unauthenticated"
| "deny_company_boundary"
| "deny_missing_membership"
| "deny_missing_grant"
| "deny_missing_consent"
| "deny_no_grant"
| "deny_policy_restricted"
| "deny_low_trust_boundary"
| "deny_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 === "agent_config:read" || action === "agent_config:update" || action === "agent_instructions:update" || action === "skill_config:update") {
return null;
}
if (
action === "agent:read" ||
action === "agent:wake" ||
action === "company_scope:read" ||
action === "decision_queue:manage" ||
action === "decision_queue:read" ||
action === "decision_triage:manage" ||
action === "issue:read" ||
action === "project:read" ||
action === "runtime:manage" ||
action === "secrets:read" ||
action === "secrets:propose"
) {
return null;
}
if (action === "issue:comment" || action === "issue:mutate") return null;
return action;
}
function canCreateAgentsLegacy(agent: { role: string; permissions: unknown }) {
if (agent.role === "ceo") return true;
// Raw agent rows may predate permission normalization; apply the same
// defaults the agent service applies on read so enforcement matches what
// the API reports.
return normalizeAgentPermissions(agent.permissions).canCreateAgents;
}
function scopeValueList(value: unknown): string[] {
if (typeof value === "string" && value.trim()) return [value.trim()];
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => entry.trim());
}
function prefixedScopeValues(grantScope: Record<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>>;
};
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];
export function authorizationService(db: Db | DbTransaction) {
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
if (!userId) return false;
if (
await db
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
.then((rows) => rows[0] ?? null)
) {
return true;
}
return false;
}
async function getActiveMembership(
companyId: string,
principalType: PrincipalType,
principalId: string,
) {
return db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, principalType),
eq(companyMemberships.principalId, principalId),
eq(companyMemberships.status, "active"),
),
)
.then((rows) => rows[0] ?? null);
}
async function loadResponsibleUserSnapshot(companyId: string, userId: string): Promise<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 = 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 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> {
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> {
return db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
parentId: issues.parentId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
checkoutRunId: issues.checkoutRunId,
status: issues.status,
executionPolicy: issues.executionPolicy,
originKind: issues.originKind,
originId: issues.originId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
}
async function loadRunPolicy(runId: string | null | undefined, companyId: string, agentId: string) {
if (!runId) return null;
const row = await db
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
return isPlainRecord(context?.executionPolicy)
? { companyId: row.companyId, executionPolicy: context.executionPolicy }
: null;
}
async function loadRunIssueId(runId: string | null | undefined, companyId: string, agentId: string) {
if (!runId) return null;
const row = await db
.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
const issueId = typeof context?.issueId === "string"
? context.issueId.trim()
: typeof context?.taskId === "string"
? context.taskId.trim()
: "";
return issueId || null;
}
async function isDirectParentReportTarget(input: {
actor: AuthorizationActor;
actorAgentId: string;
companyId: string;
resource: AuthorizationResource;
}) {
if (input.resource.type !== "issue" || !input.resource.issueId) return false;
const runIssueId = await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgentId);
if (!runIssueId || runIssueId === input.resource.issueId) return false;
const runIssue = await loadIssue(runIssueId);
return Boolean(
runIssue &&
runIssue.companyId === input.companyId &&
runIssue.assigneeAgentId === input.actorAgentId &&
runIssue.checkoutRunId === input.actor.runId &&
runIssue.parentId === input.resource.issueId,
);
}
async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) {
const row = await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
return readPolicyObject(row?.executionWorkspacePolicy, "authorizationPolicy");
}
async function loadIssueAuthorizationPolicy(companyId: string, issueId: string) {
const row = await db
.select({ executionPolicy: issues.executionPolicy })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
return readPolicyObject(row?.executionPolicy, "authorizationPolicy");
}
async function loadResourceContext(resource: AuthorizationResource) {
const issue = resource.type === "issue" && resource.issueId ? await loadIssue(resource.issueId) : null;
const projectId =
resource.type === "issue"
? issue?.projectId ?? resource.projectId ?? null
: resource.type === "project"
? resource.projectId ?? null
: null;
const project = projectId ? await loadProject(projectId) : null;
return { issue, project };
}
async function resolveActorTrust(input: {
actorAgent: AgentAuthorizationRow;
actor: AuthorizationActor;
companyId: string;
resource: AuthorizationResource;
}): Promise<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") {
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 === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
return denyBridge("Task bridge keys cannot use company-wide, peer-agent, project, runtime, or secret APIs.");
}
if (input.action === "tasks:assign") {
if (input.resource.type !== "issue") {
return denyBridge("Task bridge assignment requires an issue resource.");
}
if (!(await issueMatchesTaskBridgeCreateBoundary(input.scope, input.resource))) {
return denyBridge("Task bridge key is outside its approved parent or project boundary.");
}
if (input.resource.assigneeUserId) {
return denyBridge("Task bridge keys cannot assign work to board users.");
}
const allowedAssigneeAgentIds = input.scope.allowedAssigneeAgentIds ?? [];
if (
input.resource.assigneeAgentId &&
input.resource.assigneeAgentId !== input.actorAgentId &&
!allowedAssigneeAgentIds.includes(input.resource.assigneeAgentId)
) {
return denyBridge("Task bridge key cannot assign work to that agent.");
}
return allowBridge("Allowed by task bridge create boundary.");
}
if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") {
if (input.resource.type !== "issue") {
return denyBridge("Task bridge issue access requires an issue resource.");
}
return await issueMatchesTaskBridgeWriteBoundary({
actorAgentId: input.actorAgentId,
keyId: input.keyId,
resource: input.resource,
})
? allowBridge("Allowed for bridge-created or assigned issue.")
: denyBridge("Task bridge key can only access assigned or bridge-created issues.");
}
return denyBridge("Task bridge key cannot use this API action.");
}
function decideSkillTestAccess(input: {
action: AuthorizationAction;
resource: AuthorizationResource;
scope: SkillTestAgentKeyScope;
}): AuthorizationDecision | null {
const denySkillTest = (explanation: string) =>
deny({
action: input.action,
reason: "deny_scope",
explanation,
});
const allowSkillTest = (explanation: string) =>
allow({
action: input.action,
reason: "allow_explicit_grant",
explanation,
});
if (
input.action === "company_scope:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
input.action === "decision_triage:manage" ||
input.action === "agent:read" ||
input.action === "agent:wake" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "secrets:propose" ||
input.action === "tasks:assign"
) {
return denySkillTest("Skill-test run tokens cannot use company-wide, peer-agent, project, runtime, secret, or task-create APIs.");
}
if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") {
if (input.resource.type !== "issue") {
return denySkillTest("Skill-test issue access requires an issue resource.");
}
return input.resource.issueId === input.scope.issueId
? allowSkillTest("Allowed for the scoped skill-test issue.")
: denySkillTest("Skill-test run token can only access its own harness issue.");
}
return denySkillTest("Skill-test run token cannot use this API action.");
}
async function assignmentTargetIsInCompany(resource: AuthorizationResource) {
if (resource.type !== "issue") return true;
if (resource.assigneeAgentId) {
const target = await loadAgent(resource.assigneeAgentId);
return Boolean(
target &&
target.companyId === resource.companyId &&
isSimpleAssignableAgentStatus(target.status),
);
}
if (resource.assigneeUserId) {
return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId));
}
return true;
}
async function assignmentPolicyEffect(resource: AuthorizationResource): Promise<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);
/**
* 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.actor.type === "board") {
let taskAssignmentPolicyEffect: AssignmentPolicyEffect | null = null;
if (input.actor.source === "local_implicit") {
return allow({
action: input.action,
reason: "allow_local_board",
explanation: "Allowed because the actor is the local implicit board.",
});
}
// MCP grants always use their explicitly consented company membership,
// even when the consenting person is an instance administrator.
// A cloud_tenant actor's computed `isInstanceAdmin` flag is trusted: it
// can only be set by the attested trusted-header resolver (stack owner +
// `enableOwnerInstanceAdmin`). The `instance_user_roles` DB lookup stays
// excluded for cloud_tenant actors, so a stale or hand-inserted
// instance_admin row left behind by deployments that ran the
// pre-hardening cloud_tenant path still elevates nothing.
if (
!input.actor.ignoreInstanceAdmin && input.actor.source !== "mcp_oauth" &&
(input.actor.isInstanceAdmin ||
(input.actor.source !== "cloud_tenant" && await isInstanceAdmin(input.actor.userId)))
) {
return allow({
action: input.action,
reason: "allow_instance_admin",
explanation: "Allowed because the actor is an instance admin.",
});
}
// What instance-admin elevation used to give cloud tenant users is
// replaced by company-scoped visibility: an active membership in the
// resource company grants the same read surface a same-company agent
// gets, and non-viewer members may mutate issues inside their company.
// Cross-company access stays denied.
if (input.actor.source === "cloud_tenant" && input.actor.userId) {
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
if (membership) {
if (
input.action === "agent:read" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:read" ||
input.action === "issue:read" ||
input.action === "project:read"
) {
return allow({
action: input.action,
reason: "allow_company_member",
explanation: "Allowed by active cloud tenant company membership.",
});
}
if (
(
input.action === "issue:comment" ||
input.action === "issue:mutate" ||
input.action === "decision_queue:manage" ||
input.action === "decision_triage:manage"
) &&
membership.membershipRole !== "viewer"
) {
return allow({
action: input.action,
reason: "allow_company_member",
explanation: "Allowed by active cloud tenant company membership.",
});
}
}
}
if (!input.actor.userId) {
return deny({
action: input.action,
reason: "deny_unauthenticated",
explanation: "Board user id is required.",
});
}
if (input.action === "tasks:assign") {
if (!(await assignmentTargetIsInCompany(input.resource))) {
return deny({
action: input.action,
reason: "deny_company_boundary",
explanation: "Task assignment target agent is not active in the target company.",
});
}
const policyEffect = await assignmentPolicyEffect(input.resource);
taskAssignmentPolicyEffect = policyEffect;
const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
if (policyDeny) return policyDeny;
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
if (policyEffect.kind === "none" && membership && membership.membershipRole !== "viewer") {
return allow({
action: input.action,
reason: "allow_simple_company_member",
explanation: "Allowed by simple mode company-wide task assignment default.",
});
}
}
if (input.action === "agent_config:read") {
return decideWithAgentConfigReadGrant("user", input.actor.userId);
}
if (input.action === "agent_config:update" || input.action === "agent_instructions:update") {
return decideWithProtectedChangeGrants("user", input.actor.userId, {
direct: "agents:configure",
suggest: "agents:suggest-changes",
});
}
if (input.action === "skill_config:update") {
return decideWithProtectedChangeGrants("user", input.actor.userId, {
direct: "skills:create",
suggest: "skills:suggest-changes",
});
}
if (!permissionKey) {
if (input.action === "issue:comment" || input.action === "issue:mutate") {
if (
input.resource.type !== "issue" ||
!input.resource.issueId ||
typeof input.resource.status !== "string" ||
input.resource.assigneeAgentId === undefined ||
input.resource.assigneeUserId === undefined
) {
return deny({
action: input.action,
reason: "deny_unsupported_action",
explanation: `No board permission mapping exists for ${input.action}.`,
});
}
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
if (membership && membership.membershipRole !== "viewer") {
return allow({
action: input.action,
reason: "allow_simple_company_member",
explanation: "Allowed by standard same-company board membership issue mutation.",
});
}
if (membership) {
return deny({
action: input.action,
reason: "deny_missing_grant",
explanation: `Viewer membership does not grant ${input.action}.`,
});
}
return deny({
action: input.action,
reason: "deny_missing_membership",
explanation: `user principal ${input.actor.userId} is not an active member of company ${companyId}.`,
});
}
if (
input.action === "agent:read" ||
input.action === "agent:wake" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
input.action === "decision_triage:manage" ||
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
// Mirroring the tasks:assign carve-out above, viewers keep the
// read-only visibility actions but not the privileged ones.
const requiresNonViewer =
input.action === "agent:wake" ||
input.action === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_triage:manage";
if (membership && (!requiresNonViewer || membership.membershipRole !== "viewer")) {
return allow({
action: input.action,
reason: "allow_simple_company_member",
explanation: "Allowed by standard same-company board membership visibility.",
});
}
if (membership) {
return deny({
action: input.action,
reason: "deny_missing_grant",
explanation: `Viewer membership does not grant ${input.action}.`,
});
}
return deny({
action: input.action,
reason: "deny_missing_membership",
explanation: `user principal ${input.actor.userId} is not an active member of company ${companyId}.`,
});
}
return deny({
action: input.action,
reason: "deny_unsupported_action",
explanation: `No board permission mapping exists for ${input.action}.`,
});
}
if (input.action === "tasks:assign") {
const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId);
if (grantDecision.allowed) return grantDecision;
const policyEffect = taskAssignmentPolicyEffect ?? await assignmentPolicyEffect(input.resource);
if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect);
return grantDecision;
}
return decidePrincipalGrant({
companyId,
principalType: "user",
principalId: input.actor.userId,
action: input.action,
permissionKey,
scope: input.scope,
});
}
const actorAgentId = input.actor.agentId ?? null;
if (!actorAgentId) {
return deny({
action: input.action,
reason: "deny_unauthenticated",
explanation: "Agent authentication required.",
});
}
if (input.actor.companyId !== companyId) {
return deny({
action: input.action,
reason: "deny_company_boundary",
explanation: "Agent key cannot access another company.",
});
}
const actorAgent = await loadAgent(actorAgentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
return deny({
action: input.action,
reason: "deny_company_boundary",
explanation: "Actor agent was not found in the target company.",
});
}
if (input.actor.keyScope?.kind === "skill_test") {
const skillTestDecision = decideSkillTestAccess({
action: input.action,
resource: input.resource,
scope: input.actor.keyScope,
});
if (skillTestDecision) return skillTestDecision;
}
if (input.actor.keyScope?.kind === "task_bridge") {
const keyId = input.actor.keyId ?? null;
if (!keyId) {
return deny({
action: input.action,
reason: "deny_scope",
explanation: "Task bridge key context is missing.",
});
}
const taskBridgeDecision = await decideTaskBridgeAccess({
actorAgentId,
action: input.action,
resource: input.resource,
scope: input.actor.keyScope,
keyId,
});
if (taskBridgeDecision) return taskBridgeDecision;
}
const trustResolution = await resolveActorTrust({
actorAgent,
actor: input.actor,
companyId,
resource: input.resource,
});
const directParentReportTarget =
input.action === "issue:comment" &&
await isDirectParentReportTarget({
actor: input.actor,
actorAgentId,
companyId,
resource: input.resource,
});
const lowTrustDecision = await decideLowTrustAccess({
actorAgentId,
actor: input.actor,
action: input.action,
resource: input.resource,
resolution: trustResolution,
directParentReportTarget,
});
if (lowTrustDecision) {
if (!lowTrustDecision.allowed) return lowTrustDecision;
if (
input.action === "agent:read" ||
input.action === "agent:wake" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:read" ||
input.action === "issue:comment" ||
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
return lowTrustDecision;
}
}
const visibleIssueWriteDecision =
trustResolution.kind === "standard" &&
(input.action === "issue:comment" || input.action === "issue:mutate")
? await decideVisibleIssueWrite()
: null;
if (visibleIssueWriteDecision && !visibleIssueWriteDecision.allowed) {
return visibleIssueWriteDecision;
}
if (
trustResolution.kind === "standard" &&
input.action === "issue:comment" &&
directParentReportTarget
) {
return allow({
action: input.action,
reason: "allow_direct_parent_report",
explanation: "Allowed because the target is the current run issue's direct parent under the standard trust preset.",
});
}
if (input.action === "inbox:manage") {
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
return deny({
action: input.action,
reason: "deny_missing_membership",
explanation: "Actor agent is not active in the target company.",
});
}
const responsibleUserId = input.actor.onBehalfOfUserId?.trim() || null;
const explicitTargetUserId = typeof input.scope?.userId === "string"
? input.scope.userId.trim() || null
: null;
const targetUserId = explicitTargetUserId ?? responsibleUserId;
if (!targetUserId) {
return deny({
action: input.action,
reason: "inbox_target_user_unresolved",
explanation: "Inbox target user could not be resolved from the request or responsible-user context.",
});
}
const targetSnapshot = await getResponsibleUserSnapshot({
actor: input.actor,
companyId,
userId: targetUserId,
});
if (!targetSnapshot.userExists || !targetSnapshot.activeMembership) {
return deny({
action: input.action,
reason: "deny_missing_membership",
explanation: `Inbox target user ${targetUserId} is not an active member of company ${companyId}.`,
});
}
const policy = await db
.select({
mode: userInboxAgentPolicies.mode,
allowedAgentIds: userInboxAgentPolicies.allowedAgentIds,
})
.from(userInboxAgentPolicies)
.where(
and(
eq(userInboxAgentPolicies.companyId, companyId),
eq(userInboxAgentPolicies.userId, targetUserId),
),
)
.then((rows) => rows[0] ?? null);
if (targetUserId !== responsibleUserId) {
// A scoped grant remains an administrative override, including over a
// disabled user policy. Otherwise, a materialized target-user policy is
// explicit consent for agents selected in the profile control. The
// implicit default-open policy remains responsible-user-only so an
// absent row never becomes a company-wide cross-user grant.
const grant = await findGrant(companyId, "agent", actorAgentId, "inbox:manage");
if (grant && grant.scope?.responsibleUserOnly !== true &&
(await scopeAllows(db, companyId, grant.scope, { userId: targetUserId }))) {
return allow({
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by explicit grant inbox:manage.",
inboxPolicyMode: "grant_override",
grant: {
principalType: "agent",
principalId: actorAgentId,
permissionKey: "inbox:manage",
scope: grant.scope ?? null,
},
});
}
if (policy?.mode === "disabled") {
return deny({
action: input.action,
reason: "inbox_management_disabled",
explanation: `Inbox management is disabled for user ${targetUserId}.`,
});
}
if (policy?.mode === "allowlist" && !policy.allowedAgentIds.includes(actorAgentId)) {
return deny({
action: input.action,
reason: "inbox_agent_not_allowed",
explanation: `Agent ${actorAgentId} is not allowed to manage user ${targetUserId}'s inbox.`,
});
}
if (policy?.mode === "open" || policy?.mode === "allowlist") {
return allow({
action: input.action,
reason: "allow_user_inbox_policy",
inboxPolicyMode: policy.mode,
explanation: policy.mode === "allowlist"
? "Allowed by the target user's inbox agent allowlist."
: "Allowed by the target user's saved open inbox policy.",
});
}
if (grant) {
return deny({
action: input.action,
reason: "deny_scope",
explanation: "Permission inbox:manage does not cover the requested user.",
grant: {
principalType: "agent",
principalId: actorAgentId,
permissionKey: "inbox:manage",
scope: grant.scope ?? null,
},
});
}
return deny({
action: input.action,
reason: "deny_missing_grant",
explanation: "Missing permission: inbox:manage.",
});
}
if (policy?.mode === "disabled") {
return deny({
action: input.action,
reason: "inbox_management_disabled",
explanation: `Inbox management is disabled for user ${targetUserId}.`,
});
}
if (policy?.mode === "allowlist" && !policy.allowedAgentIds.includes(actorAgentId)) {
return deny({
action: input.action,
reason: "inbox_agent_not_allowed",
explanation: `Agent ${actorAgentId} is not allowed to manage user ${targetUserId}'s inbox.`,
});
}
return allow({
action: input.action,
reason: "allow_self",
inboxPolicyMode: policy?.mode ?? "open",
explanation: policy?.mode === "allowlist"
? "Allowed by the responsible user's inbox agent allowlist."
: "Allowed by the responsible user's default-open inbox policy.",
});
}
if (
input.action === "agent:read" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:read" ||
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
return allow({
action: input.action,
reason: "allow_company_agent",
explanation: "Allowed by standard same-company agent visibility.",
});
}
if (input.action === "decision_queue:manage" || input.action === "decision_triage:manage") {
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
return deny({
action: input.action,
reason: "deny_missing_membership",
explanation: "Actor agent is not active in the target company.",
});
}
return allow({
action: input.action,
reason: "allow_company_agent",
explanation: "Allowed for an active standard-scope company agent.",
});
}
if (input.action === "agent:wake" && input.resource.type === "agent" && input.resource.agentId === actorAgentId) {
return allow({
action: input.action,
reason: "allow_self",
explanation: "Allowed because the actor is waking itself.",
});
}
if (input.action === "tasks:assign") {
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
return deny({
action: input.action,
reason: "deny_missing_membership",
explanation: "Actor agent is not active for simple mode task assignment.",
});
}
if (!(await assignmentTargetIsInCompany(input.resource))) {
return deny({
action: input.action,
reason: "deny_company_boundary",
explanation: "Task assignment target agent is not active in the target company.",
});
}
const policyEffect = await assignmentPolicyEffect(input.resource);
const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
if (policyDeny) return policyDeny;
if (policyEffect.kind === "restricted") {
const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId);
if (grantDecision.allowed) return grantDecision;
return denyRestrictedAssignmentPolicy(policyEffect);
}
if (trustResolution.kind === "standard") return decideVisibleIssueWrite();
return allow({
action: input.action,
reason: "allow_simple_company_member",
explanation: "Allowed by the existing bounded task assignment rule.",
});
}
if (input.action === "issue:comment" || input.action === "issue:mutate") {
const resource = input.resource.type === "issue" ? input.resource : null;
if (resource?.assigneeAgentId === actorAgentId) {
return allow({
action: input.action,
reason: "allow_self",
explanation: "Allowed because the actor owns the assigned issue.",
});
}
if (!resource?.assigneeAgentId) {
return allow({
action: input.action,
reason: "allow_company_agent",
explanation: "Allowed because the issue has no agent assignee.",
});
}
if (
input.action === "issue:comment" &&
resource?.issueId &&
await agentHasMentionGrantOnIssue({
action: input.action,
companyId,
issueId: resource.issueId,
issueAssigneeAgentId: resource.assigneeAgentId ?? null,
actorAgentId,
})
) {
return allowIssueMentionGrant(input.action);
}
if (visibleIssueWriteDecision) return visibleIssueWriteDecision;
}
if (
input.action === "agent_config:update" &&
input.resource.type === "agent" &&
input.resource.agentId === actorAgentId &&
!scopeBoolean(input.scope, "requiresChangeGrant")
) {
return allow({
action: input.action,
reason: "allow_self",
explanation: "Allowed because the actor is updating its own agent configuration.",
});
}
if (input.action === "agent_config:read") {
if (input.resource.type === "agent" && input.resource.agentId === actorAgentId) {
return allow({
action: input.action,
reason: "allow_self",
explanation: "Allowed because the actor is reading its own agent configuration.",
});
}
return decideWithAgentConfigReadGrant("agent", actorAgentId);
}
if (input.action === "agent_instructions:update") {
if (!isSimpleAssignableAgentStatus(actorAgent.status) || !input.actor.onBehalfOfUserId) {
return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Instruction edits require an active agent and a responsible user." });
}
// Explicit configure/suggest restrictions still govern content changes.
// Only self edits may fall back to the responsible user's target access.
const restricted = await decideWithProtectedChangeGrants("agent", actorAgentId, {
direct: "agents:configure", suggest: "agents:suggest-changes",
});
if (restricted.reason !== "deny_no_grant" || input.resource.type !== "agent" || input.resource.agentId !== actorAgentId) return restricted;
return allow({ action: input.action, reason: "allow_self", explanation: "Own instruction content edit, subject to the responsible user's target edit access." });
}
if (input.action === "agent_config:update") {
return decideWithProtectedChangeGrants("agent", actorAgentId, {
direct: "agents:configure",
suggest: "agents:suggest-changes",
});
}
if (input.action === "skill_config:update") {
return decideWithProtectedChangeGrants("agent", actorAgentId, {
direct: "skills:create",
suggest: "skills:suggest-changes",
});
}
if (permissionKey) {
const grantDecision = await decidePrincipalGrant({
companyId,
principalType: "agent",
principalId: actorAgentId,
action: input.action,
permissionKey,
scope: input.scope,
});
if (grantDecision.allowed) return grantDecision;
}
if (input.action === "agents:create" && canCreateAgentsLegacy(actorAgent)) {
return allow({
action: input.action,
reason: "allow_legacy_agent_creator",
explanation: "Allowed by legacy agent creator authority.",
});
}
// Active-checkout management deliberately does not ride on
// canCreateAgents: that flag is default-on for standard-trust agents, and
// coupling would let any peer write over another agent's checked-out
// issue. CEOs, explicit grants, and the manager chain remain the paths.
if (input.action === "tasks:manage_active_checkouts" && actorAgent.role === "ceo") {
return allow({
action: input.action,
reason: "allow_legacy_agent_creator",
explanation: "Allowed by legacy agent creator authority.",
});
}
if (
input.action === "tasks:manage_active_checkouts" &&
input.resource.type === "issue" &&
input.resource.assigneeAgentId &&
await isManagerOf(companyId, actorAgentId, input.resource.assigneeAgentId)
) {
return allow({
action: input.action,
reason: "allow_manager_chain",
explanation: "Allowed because the actor manages the issue assignee in the reporting chain.",
});
}
return deny({
action: input.action,
reason: "deny_missing_grant",
explanation: permissionKey
? `Missing permission: ${permissionKey}.`
: `No agent permission mapping exists for ${input.action}.`,
});
}
async function applyResponsibleUserIntersection(
input: {
actor: AuthorizationActor;
action: AuthorizationAction;
resource: AuthorizationResource;
scope?: Record<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 decide(input: {
actor: AuthorizationActor;
action: AuthorizationAction;
resource: AuthorizationResource;
scope?: Record<string, unknown> | null;
}): Promise<AuthorizationDecision> {
const agentDecision = await decideBase(input);
return applyResponsibleUserIntersection(input, agentDecision);
}
// 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,
projectDiscoveryCandidateIds,
decidePrincipalGrant,
};
}