mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Harden API route authorization boundaries (#4122)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The REST API is the control-plane boundary for companies, agents, plugins, adapters, costs, invites, and issue mutations. > - Several routes still relied on broad board or company access checks without consistently enforcing the narrower actor, company, and active-checkout boundaries those operations require. > - That can allow agents or non-admin users to mutate sensitive resources outside the intended governance path. > - This pull request hardens the route authorization layer and adds regression coverage for the audited API surfaces. > - The benefit is tighter multi-company isolation, safer plugin and adapter administration, and stronger enforcement of active issue ownership. ## What Changed - Added route-level authorization checks for budgets, plugin administration/scoped routes, adapter management, company import/export, direct agent creation, invite test resolution, and issue mutation/write surfaces. - Enforced active checkout ownership for agent-authenticated issue mutations, while preserving explicit management overrides for permitted managers. - Restricted sensitive adapter and plugin management operations to instance-admin or properly scoped actors. - Tightened company portability and invite probing routes so agents cannot cross company boundaries. - Updated access constants and the Company Access UI copy for the new active-checkout management grant. - Added focused regression tests covering cross-company denial, agent self-mutation denial, admin-only operations, and active checkout ownership. - Rebased the branch onto `public-gh/master` and fixed validation fallout from the rebase: heartbeat-context route ordering and a company import/export e2e fixture that now opts out of direct-hire approval before using direct agent creation. - Updated onboarding and signoff e2e setup to create seed agents through `/agent-hires` plus board approval, so they remain compatible with the approval-gated new-agent default. - Addressed Greptile feedback by removing a duplicate company export API alias, avoiding N+1 reporting-chain lookups in active-checkout override checks, allowing agent mutations on unassigned `in_progress` issues, and blocking NAT64 invite-probe targets. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-goal-context-routes.test.ts cli/src/__tests__/company-import-export-e2e.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/adapter-routes-authz.test.ts server/src/__tests__/agent-permissions-routes.test.ts server/src/__tests__/company-portability-routes.test.ts server/src/__tests__/costs-service.test.ts server/src/__tests__/invite-test-resolution-route.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/agent-adapter-validation-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/invite-test-resolution-route.test.ts` - `pnpm -r typecheck` - `pnpm --filter server typecheck` - `pnpm --filter ui typecheck` - `pnpm build` - `pnpm test:e2e -- tests/e2e/onboarding.spec.ts tests/e2e/signoff-policy.spec.ts` - `pnpm test:e2e -- tests/e2e/signoff-policy.spec.ts` - `pnpm test:run` was also run. It failed under default full-suite parallelism with two order-dependent failures in `plugin-routes-authz.test.ts` and `routines-e2e.test.ts`; both files passed when rerun directly together with `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/routines-e2e.test.ts`. ## Risks - Medium risk: this changes authorization behavior across multiple sensitive API surfaces, so callers that depended on broad board/company access may now receive `403` or `409` until they use the correct governance path. - Direct agent creation now respects the company-level board-approval requirement; integrations that need pending hires should use `/api/companies/:companyId/agent-hires`. - Active in-progress issue mutations now require checkout ownership or an explicit management override, which may reveal workflow assumptions in older automation. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent, tool-using workflow with local shell, Git, GitHub CLI, and repository tests. ## 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 - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
549ef11c14
commit
7a329fb8bb
22 changed files with 1903 additions and 138 deletions
|
|
@ -4,7 +4,12 @@ import {
|
|||
randomBytes,
|
||||
timingSafeEqual
|
||||
} from "node:crypto";
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import fs from "node:fs";
|
||||
import type { IncomingMessage, RequestOptions as HttpRequestOptions } from "node:http";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Router } from "express";
|
||||
|
|
@ -84,6 +89,7 @@ const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|||
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
|
||||
const INVITE_TOKEN_MAX_RETRIES = 5;
|
||||
const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000;
|
||||
const INVITE_RESOLUTION_DNS_TIMEOUT_MS = 3_000;
|
||||
|
||||
type MemberGrantPayload = {
|
||||
permissionKey: PermissionKey;
|
||||
|
|
@ -2101,44 +2107,259 @@ type InviteResolutionProbe = {
|
|||
message: string;
|
||||
};
|
||||
|
||||
type InviteResolutionLookupResult = {
|
||||
address: string;
|
||||
family?: number;
|
||||
};
|
||||
|
||||
type ResolvedInviteResolutionTarget = {
|
||||
url: URL;
|
||||
resolvedAddress: string;
|
||||
resolvedAddresses: string[];
|
||||
hostHeader: string;
|
||||
tlsServername?: string;
|
||||
};
|
||||
|
||||
type InviteResolutionHeadResponse = {
|
||||
httpStatus: number | null;
|
||||
};
|
||||
|
||||
type InviteResolutionNetwork = {
|
||||
lookup(hostname: string): Promise<InviteResolutionLookupResult[]>;
|
||||
requestHead(
|
||||
target: ResolvedInviteResolutionTarget,
|
||||
timeoutMs: number
|
||||
): Promise<InviteResolutionHeadResponse>;
|
||||
};
|
||||
|
||||
function parseIpv4Address(address: string) {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const parsed = parts.map((part) => {
|
||||
if (!/^\d+$/.test(part)) return NaN;
|
||||
return Number(part);
|
||||
});
|
||||
if (parsed.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as [number, number, number, number];
|
||||
}
|
||||
|
||||
function isPrivateOrReservedIpv4(address: string) {
|
||||
const octets = parseIpv4Address(address);
|
||||
if (!octets) return true;
|
||||
const [a, b, c] = octets;
|
||||
if (a === 0) return true;
|
||||
if (a === 10) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
if (a === 127) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 0 && c === 0) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 192 && b === 0 && c === 2) return true;
|
||||
if (a === 192 && b === 88 && c === 99) return true;
|
||||
if (a === 198 && (b === 18 || b === 19)) return true;
|
||||
if (a === 198 && b === 51 && c === 100) return true;
|
||||
if (a === 203 && b === 0 && c === 113) return true;
|
||||
if (a >= 224) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseMappedIpv4Hex(address: string) {
|
||||
const match = address.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (!match) return null;
|
||||
const hi = Number.parseInt(match[1]!, 16);
|
||||
const lo = Number.parseInt(match[2]!, 16);
|
||||
if (!Number.isInteger(hi) || !Number.isInteger(lo)) return null;
|
||||
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
||||
}
|
||||
|
||||
function isPrivateOrReservedIpv6(address: string) {
|
||||
const lower = address.toLowerCase();
|
||||
if (lower.startsWith("::ffff:")) {
|
||||
const mappedIpv4 = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
if (mappedIpv4?.[1]) return isPrivateOrReservedIpv4(mappedIpv4[1]);
|
||||
const mappedIpv4Hex = parseMappedIpv4Hex(lower);
|
||||
if (mappedIpv4Hex) return isPrivateOrReservedIpv4(mappedIpv4Hex);
|
||||
return true;
|
||||
}
|
||||
if (lower === "::" || lower === "::1") return true;
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
||||
if (/^fe[89ab]/.test(lower)) return true;
|
||||
if (lower.startsWith("ff")) return true;
|
||||
if (lower === "100::" || lower.startsWith("100:")) return true;
|
||||
if (lower.startsWith("2001:db8:") || lower === "2001:db8::") return true;
|
||||
if (lower.startsWith("2001:2:") || lower === "2001:2::") return true;
|
||||
if (lower.startsWith("2002:")) return true;
|
||||
if (lower.startsWith("64:ff9b:")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPublicIpAddress(address: string) {
|
||||
const ipVersion = isIP(address);
|
||||
if (ipVersion === 4) return !isPrivateOrReservedIpv4(address);
|
||||
if (ipVersion === 6) return !isPrivateOrReservedIpv6(address);
|
||||
return false;
|
||||
}
|
||||
|
||||
function hostnameForResolution(url: URL) {
|
||||
return url.hostname.replace(/^\[|\]$/g, "");
|
||||
}
|
||||
|
||||
async function defaultInviteResolutionLookup(
|
||||
hostname: string
|
||||
): Promise<InviteResolutionLookupResult[]> {
|
||||
return dnsLookup(hostname, { all: true, verbatim: true });
|
||||
}
|
||||
|
||||
async function defaultInviteResolutionHeadRequest(
|
||||
target: ResolvedInviteResolutionTarget,
|
||||
timeoutMs: number
|
||||
): Promise<InviteResolutionHeadResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = target.url;
|
||||
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const options: HttpRequestOptions & { servername?: string } = {
|
||||
protocol: url.protocol,
|
||||
hostname: target.resolvedAddress,
|
||||
port: url.port || undefined,
|
||||
method: "HEAD",
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers: {
|
||||
Host: target.hostHeader
|
||||
}
|
||||
};
|
||||
if (target.tlsServername) {
|
||||
options.servername = target.tlsServername;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const req = request(options, (response: IncomingMessage) => {
|
||||
settled = true;
|
||||
response.resume();
|
||||
resolve({ httpStatus: response.statusCode ?? null });
|
||||
});
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
if (settled) return;
|
||||
const error = new Error("Invite resolution probe timed out");
|
||||
error.name = "AbortError";
|
||||
req.destroy(error);
|
||||
});
|
||||
req.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const defaultInviteResolutionNetwork: InviteResolutionNetwork = {
|
||||
lookup: defaultInviteResolutionLookup,
|
||||
requestHead: defaultInviteResolutionHeadRequest
|
||||
};
|
||||
|
||||
let inviteResolutionNetwork = defaultInviteResolutionNetwork;
|
||||
|
||||
export function setInviteResolutionNetworkForTest(
|
||||
network: Partial<InviteResolutionNetwork> | null
|
||||
) {
|
||||
inviteResolutionNetwork = network
|
||||
? { ...defaultInviteResolutionNetwork, ...network }
|
||||
: defaultInviteResolutionNetwork;
|
||||
}
|
||||
|
||||
async function lookupInviteResolutionHostname(hostname: string) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
inviteResolutionNetwork.lookup(hostname),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
badRequest(
|
||||
`url hostname DNS lookup timed out after ${INVITE_RESOLUTION_DNS_TIMEOUT_MS}ms`
|
||||
)
|
||||
),
|
||||
INVITE_RESOLUTION_DNS_TIMEOUT_MS
|
||||
);
|
||||
})
|
||||
]);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "status" in error) throw error;
|
||||
throw badRequest("url hostname could not be resolved");
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInviteResolutionTarget(
|
||||
url: URL
|
||||
): Promise<ResolvedInviteResolutionTarget> {
|
||||
const hostname = hostnameForResolution(url);
|
||||
const results = await lookupInviteResolutionHostname(hostname);
|
||||
if (results.length === 0) {
|
||||
throw badRequest("url hostname did not resolve to any addresses");
|
||||
}
|
||||
|
||||
const resolvedAddresses = results.map((result) => result.address);
|
||||
const unsafeAddress = resolvedAddresses.find((address) => !isPublicIpAddress(address));
|
||||
if (unsafeAddress) {
|
||||
throw badRequest(
|
||||
"url resolves to a private, local, multicast, or reserved address"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
resolvedAddress: resolvedAddresses[0]!,
|
||||
resolvedAddresses,
|
||||
hostHeader: url.host,
|
||||
tlsServername: url.protocol === "https:" && isIP(hostname) === 0
|
||||
? hostname
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function probeInviteResolutionTarget(
|
||||
url: URL,
|
||||
target: ResolvedInviteResolutionTarget,
|
||||
timeoutMs: number
|
||||
): Promise<InviteResolutionProbe> {
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: controller.signal
|
||||
});
|
||||
const response = await inviteResolutionNetwork.requestHead(target, timeoutMs);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 401 ||
|
||||
response.status === 403 ||
|
||||
response.status === 404 ||
|
||||
response.status === 405 ||
|
||||
response.status === 422 ||
|
||||
response.status === 500 ||
|
||||
response.status === 501
|
||||
response.httpStatus !== null &&
|
||||
(
|
||||
(response.httpStatus >= 200 && response.httpStatus < 300) ||
|
||||
response.httpStatus === 401 ||
|
||||
response.httpStatus === 403 ||
|
||||
response.httpStatus === 404 ||
|
||||
response.httpStatus === 405 ||
|
||||
response.httpStatus === 422 ||
|
||||
response.httpStatus === 500 ||
|
||||
response.httpStatus === 501
|
||||
)
|
||||
) {
|
||||
return {
|
||||
status: "reachable",
|
||||
method: "HEAD",
|
||||
durationMs,
|
||||
httpStatus: response.status,
|
||||
message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`
|
||||
httpStatus: response.httpStatus,
|
||||
message: `Webhook endpoint responded to HEAD with HTTP ${response.httpStatus}.`
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "unreachable",
|
||||
method: "HEAD",
|
||||
durationMs,
|
||||
httpStatus: response.status,
|
||||
message: `Webhook endpoint probe returned HTTP ${response.status}.`
|
||||
httpStatus: response.httpStatus,
|
||||
message: response.httpStatus === null
|
||||
? "Webhook endpoint probe did not return an HTTP status."
|
||||
: `Webhook endpoint probe returned HTTP ${response.httpStatus}.`
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
|
@ -2161,8 +2382,6 @@ async function probeInviteResolutionTarget(
|
|||
? error.message
|
||||
: "Webhook endpoint probe failed."
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2927,7 +3146,8 @@ export function accessRoutes(
|
|||
const timeoutMs = Number.isFinite(parsedTimeoutMs)
|
||||
? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs)))
|
||||
: 5000;
|
||||
const probe = await probeInviteResolutionTarget(target, timeoutMs);
|
||||
const resolvedTarget = await resolveInviteResolutionTarget(target);
|
||||
const probe = await probeInviteResolutionTarget(resolvedTarget, timeoutMs);
|
||||
res.json({
|
||||
inviteId: invite.id,
|
||||
testResolutionPath: `/api/invites/${token}/test-resolution`,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
* - Installing external adapters from npm packages or local paths
|
||||
* - Unregistering external adapters
|
||||
*
|
||||
* All routes require board-level authentication (assertBoard middleware).
|
||||
* Read-only routes require board org access. Mutating adapter management
|
||||
* routes require instance-admin access because they can install, reload, or
|
||||
* toggle server-side adapter code for the whole Paperclip instance.
|
||||
*
|
||||
* @module server/routes/adapters
|
||||
*/
|
||||
|
|
@ -41,7 +43,7 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
|
|||
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
|
||||
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { assertBoardOrgAccess } from "./authz.js";
|
||||
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
|
@ -192,6 +194,9 @@ export function adapterRoutes() {
|
|||
* its model count, and load status.
|
||||
*/
|
||||
router.get("/adapters", async (_req, res) => {
|
||||
// Adapter inventory is needed by ordinary board members when creating or
|
||||
// editing company agents. Mutating adapter management routes below remain
|
||||
// instance-admin only because they affect the whole server runtime.
|
||||
assertBoardOrgAccess(_req);
|
||||
|
||||
const registeredAdapters = listServerAdapters();
|
||||
|
|
@ -218,7 +223,7 @@ export function adapterRoutes() {
|
|||
* - version?: string — target version for npm packages
|
||||
*/
|
||||
router.post("/adapters/install", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
|
||||
|
||||
|
|
@ -350,7 +355,7 @@ export function adapterRoutes() {
|
|||
* Request body: { "disabled": boolean }
|
||||
*/
|
||||
router.patch("/adapters/:type", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
const { disabled } = req.body as { disabled?: boolean };
|
||||
|
|
@ -385,7 +390,7 @@ export function adapterRoutes() {
|
|||
* keep the adapter they started with.
|
||||
*/
|
||||
router.patch("/adapters/:type/override", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
const { paused } = req.body as { paused?: boolean };
|
||||
|
|
@ -413,7 +418,7 @@ export function adapterRoutes() {
|
|||
* Unregister an external adapter. Built-in adapters cannot be removed.
|
||||
*/
|
||||
router.delete("/adapters/:type", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
|
||||
|
|
@ -488,7 +493,7 @@ export function adapterRoutes() {
|
|||
* Cannot be used on built-in adapter types.
|
||||
*/
|
||||
router.post("/adapters/:type/reload", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
|
|
@ -540,7 +545,7 @@ export function adapterRoutes() {
|
|||
// This is a convenience shortcut for remove + install with the same
|
||||
// package name, but without the risk of losing the store record.
|
||||
router.post("/adapters/:type/reinstall", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
|
|
@ -613,6 +618,8 @@ export function adapterRoutes() {
|
|||
const CONFIG_SCHEMA_TTL_MS = 30_000;
|
||||
|
||||
router.get("/adapters/:type/config-schema", async (req, res) => {
|
||||
// Config schemas are read-only form metadata used when org members create
|
||||
// or edit agents; they do not install or execute new adapter code.
|
||||
assertBoardOrgAccess(req);
|
||||
const { type } = req.params;
|
||||
|
||||
|
|
@ -651,6 +658,8 @@ export function adapterRoutes() {
|
|||
// The adapter package must export a "./ui-parser" entry in package.json
|
||||
// pointing to a self-contained ESM module with zero runtime dependencies.
|
||||
router.get("/adapters/:type/ui-parser.js", (req, res) => {
|
||||
// UI parsers are read-only assets for displaying existing run output.
|
||||
// Runtime-changing adapter management routes above require instance admin.
|
||||
assertBoardOrgAccess(req);
|
||||
const { type } = req.params;
|
||||
const source = getOrExtractUiParserSource(type);
|
||||
|
|
|
|||
|
|
@ -1552,10 +1552,21 @@ export function agentRoutes(db: Db) {
|
|||
|
||||
router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertCanCreateAgentsForCompany(req, companyId);
|
||||
|
||||
if (req.actor.type === "agent") {
|
||||
assertBoard(req);
|
||||
const company = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
return;
|
||||
}
|
||||
if (company.requireBoardApprovalForNewAgents) {
|
||||
throw conflict(
|
||||
"Direct agent creation requires board approval. Use POST /api/companies/:companyId/agent-hires to create a pending hire approval.",
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
|
|
@ -1563,6 +1574,14 @@ export function agentRoutes(db: Db) {
|
|||
...createInput
|
||||
} = req.body;
|
||||
createInput.adapterType = assertKnownAdapterType(createInput.adapterType);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(createInput.adapterConfig),
|
||||
);
|
||||
assertNoAgentInstructionsConfigMutation(
|
||||
req,
|
||||
(createInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
createInput.adapterType,
|
||||
((createInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
|||
|
||||
router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertCanManagePortability(req, companyId, "exports");
|
||||
const result = await portability.exportBundle(companyId, req.body);
|
||||
res.json(result);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -290,13 +290,7 @@ export function costRoutes(db: Db) {
|
|||
}
|
||||
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
if (req.actor.type === "agent") {
|
||||
if (req.actor.agentId !== agentId) {
|
||||
res.status(403).json({ error: "Agent can only change its own budget" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
assertBoard(req);
|
||||
|
||||
const updated = await agents.update(agentId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
|
||||
if (!updated) {
|
||||
|
|
|
|||
|
|
@ -407,7 +407,39 @@ export function issueRoutes(
|
|||
return null;
|
||||
}
|
||||
|
||||
async function assertAgentRunCheckoutOwnership(
|
||||
async function hasActiveCheckoutManagementOverride(
|
||||
actorAgentId: string,
|
||||
companyId: string,
|
||||
assigneeAgentId: string,
|
||||
) {
|
||||
const allowedByGrant = await access.hasPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
actorAgentId,
|
||||
"tasks:manage_active_checkouts",
|
||||
);
|
||||
if (allowedByGrant) return true;
|
||||
|
||||
const companyAgents = await agentsSvc.list(companyId);
|
||||
const agentsById = new Map(companyAgents.map((agent) => [agent.id, agent]));
|
||||
const actorAgent = agentsById.get(actorAgentId);
|
||||
if (!actorAgent) return false;
|
||||
if (canCreateAgentsLegacy(actorAgent)) return true;
|
||||
|
||||
// Reporting-chain managers may intervene in an agent's active checkout
|
||||
// without taking the task over. Peers must own the checkout/run first.
|
||||
let cursor: string | null = assigneeAgentId;
|
||||
for (let depth = 0; cursor && depth < 50; depth += 1) {
|
||||
const assignee = agentsById.get(cursor);
|
||||
if (!assignee) return false;
|
||||
if (assignee.reportsTo === actorAgentId) return true;
|
||||
cursor = assignee.reportsTo;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function assertAgentIssueMutationAllowed(
|
||||
req: Request,
|
||||
res: Response,
|
||||
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
|
||||
|
|
@ -418,9 +450,23 @@ export function issueRoutes(
|
|||
res.status(403).json({ error: "Agent authentication required" });
|
||||
return false;
|
||||
}
|
||||
if (issue.status !== "in_progress" || issue.assigneeAgentId !== actorAgentId) {
|
||||
if (issue.status !== "in_progress" || issue.assigneeAgentId === null) {
|
||||
return true;
|
||||
}
|
||||
if (issue.assigneeAgentId !== actorAgentId) {
|
||||
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
|
||||
return true;
|
||||
}
|
||||
res.status(409).json({
|
||||
error: "Issue is checked out by another agent",
|
||||
details: {
|
||||
issueId: issue.id,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
actorAgentId,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const runId = requireAgentRunId(req, res);
|
||||
if (!runId) return false;
|
||||
const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId);
|
||||
|
|
@ -725,43 +771,6 @@ export function issueRoutes(
|
|||
res.json(removed);
|
||||
});
|
||||
|
||||
router.get("/issues/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
svc.getRelationSummaries(issue.id),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
const currentExecutionWorkspace = issue.executionWorkspaceId
|
||||
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
||||
: null;
|
||||
const workProducts = await workProductsSvc.listForIssue(issue.id);
|
||||
res.json({
|
||||
...issue,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
ancestors,
|
||||
blockedBy: relations.blockedBy,
|
||||
blocks: relations.blocks,
|
||||
...documentPayload,
|
||||
project: project ?? null,
|
||||
goal: goal ?? null,
|
||||
mentionedProjects,
|
||||
currentExecutionWorkspace,
|
||||
workProducts,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/heartbeat-context", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
@ -854,6 +863,43 @@ export function issueRoutes(
|
|||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
svc.getRelationSummaries(issue.id),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
const currentExecutionWorkspace = issue.executionWorkspaceId
|
||||
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
||||
: null;
|
||||
const workProducts = await workProductsSvc.listForIssue(issue.id);
|
||||
res.json({
|
||||
...issue,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
ancestors,
|
||||
blockedBy: relations.blockedBy,
|
||||
blocks: relations.blocks,
|
||||
...documentPayload,
|
||||
project: project ?? null,
|
||||
goal: goal ?? null,
|
||||
mentionedProjects,
|
||||
currentExecutionWorkspace,
|
||||
workProducts,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/work-products", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
@ -909,6 +955,7 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
|
|
@ -980,6 +1027,7 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
|
|
@ -1068,6 +1116,7 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, {
|
||||
...req.body,
|
||||
projectId: req.body.projectId ?? issue.projectId ?? null,
|
||||
|
|
@ -1099,6 +1148,12 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const issue = await svc.getById(existing.issueId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
const product = await workProductsSvc.update(id, req.body);
|
||||
if (!product) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
|
|
@ -1127,6 +1182,12 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const issue = await svc.getById(existing.issueId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
const removed = await workProductsSvc.remove(id);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
|
|
@ -1294,6 +1355,8 @@ export function issueRoutes(
|
|||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -1326,6 +1389,8 @@ export function issueRoutes(
|
|||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
|
||||
|
||||
await issueApprovalsSvc.unlink(id, approvalId);
|
||||
|
|
@ -1457,7 +1522,7 @@ export function issueRoutes(
|
|||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const isClosed = isClosedIssueStatus(existing.status);
|
||||
|
|
@ -2053,6 +2118,7 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
||||
const attachments = await svc.listAttachments(id);
|
||||
|
||||
const issue = await svc.remove(id);
|
||||
|
|
@ -2166,7 +2232,7 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
||||
const actorRunId = requireAgentRunId(req, res);
|
||||
if (req.actor.type === "agent" && !actorRunId) return;
|
||||
|
||||
|
|
@ -2255,7 +2321,7 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
|
||||
const comment = await svc.getComment(commentId);
|
||||
if (!comment || comment.issueId !== id) {
|
||||
|
|
@ -2400,7 +2466,7 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
||||
if (closedExecutionWorkspace) {
|
||||
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||
|
|
@ -2730,6 +2796,7 @@ export function issueRoutes(
|
|||
res.status(422).json({ error: "Issue does not belong to company" });
|
||||
return;
|
||||
}
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res);
|
||||
|
|
@ -2840,6 +2907,12 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, attachment.companyId);
|
||||
const issue = await svc.getById(attachment.issueId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
|
||||
try {
|
||||
await storage.deleteObject(attachment.companyId, attachment.objectKey);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,14 @@ import { Router } from "express";
|
|||
import type { Request, Response } from "express";
|
||||
import { and, desc, eq, gte } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
heartbeatRuns,
|
||||
pluginLogs,
|
||||
pluginWebhookDeliveries,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
PluginApiRouteDeclaration,
|
||||
PluginStatus,
|
||||
|
|
@ -59,7 +66,7 @@ import {
|
|||
getActorInfo,
|
||||
} from "./authz.js";
|
||||
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
|
||||
import { forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
|
||||
/** UI slot declaration extracted from plugin manifest */
|
||||
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
|
||||
|
|
@ -548,6 +555,52 @@ export function pluginRoutes(
|
|||
})));
|
||||
}
|
||||
|
||||
function assertPluginBridgeScope(req: Request, companyId: unknown): string | undefined {
|
||||
if (companyId === undefined || companyId === null) {
|
||||
assertInstanceAdmin(req);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof companyId !== "string" || companyId.trim().length === 0) {
|
||||
throw badRequest('"companyId" must be a non-empty string when provided');
|
||||
}
|
||||
assertCompanyAccess(req, companyId);
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function validateToolRunContextScope(runContext: ToolRunContext): Promise<string | null> {
|
||||
const [agent] = await db
|
||||
.select({ companyId: agents.companyId })
|
||||
.from(agents)
|
||||
.where(eq(agents.id, runContext.agentId))
|
||||
.limit(1);
|
||||
if (!agent || agent.companyId !== runContext.companyId) {
|
||||
return '"runContext.agentId" does not belong to "runContext.companyId"';
|
||||
}
|
||||
|
||||
const [run] = await db
|
||||
.select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runContext.runId))
|
||||
.limit(1);
|
||||
if (!run || run.companyId !== runContext.companyId) {
|
||||
return '"runContext.runId" does not belong to "runContext.companyId"';
|
||||
}
|
||||
if (run.agentId !== runContext.agentId) {
|
||||
return '"runContext.runId" does not belong to "runContext.agentId"';
|
||||
}
|
||||
|
||||
const [project] = await db
|
||||
.select({ companyId: projects.companyId })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, runContext.projectId))
|
||||
.limit(1);
|
||||
if (!project || project.companyId !== runContext.companyId) {
|
||||
return '"runContext.projectId" does not belong to "runContext.companyId"';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/plugins
|
||||
*
|
||||
|
|
@ -742,6 +795,11 @@ export function pluginRoutes(
|
|||
}
|
||||
|
||||
assertCompanyAccess(req, runContext.companyId);
|
||||
const scopeError = await validateToolRunContextScope(runContext);
|
||||
if (scopeError) {
|
||||
res.status(403).json({ error: scopeError });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the tool exists
|
||||
const registeredTool = toolDeps.toolDispatcher.getTool(tool);
|
||||
|
|
@ -1020,9 +1078,7 @@ export function pluginRoutes(
|
|||
return;
|
||||
}
|
||||
|
||||
if (body.companyId) {
|
||||
assertCompanyAccess(req, body.companyId);
|
||||
}
|
||||
assertPluginBridgeScope(req, body.companyId);
|
||||
|
||||
try {
|
||||
const result = await bridgeDeps.workerManager.call(
|
||||
|
|
@ -1103,9 +1159,7 @@ export function pluginRoutes(
|
|||
return;
|
||||
}
|
||||
|
||||
if (body.companyId) {
|
||||
assertCompanyAccess(req, body.companyId);
|
||||
}
|
||||
assertPluginBridgeScope(req, body.companyId);
|
||||
|
||||
try {
|
||||
const result = await bridgeDeps.workerManager.call(
|
||||
|
|
@ -1186,9 +1240,7 @@ export function pluginRoutes(
|
|||
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
|
||||
} | undefined;
|
||||
|
||||
if (body?.companyId) {
|
||||
assertCompanyAccess(req, body.companyId);
|
||||
}
|
||||
assertPluginBridgeScope(req, body?.companyId);
|
||||
|
||||
try {
|
||||
const result = await bridgeDeps.workerManager.call(
|
||||
|
|
@ -1265,9 +1317,7 @@ export function pluginRoutes(
|
|||
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
|
||||
} | undefined;
|
||||
|
||||
if (body?.companyId) {
|
||||
assertCompanyAccess(req, body.companyId);
|
||||
}
|
||||
assertPluginBridgeScope(req, body?.companyId);
|
||||
|
||||
try {
|
||||
const result = await bridgeDeps.workerManager.call(
|
||||
|
|
@ -2140,7 +2190,7 @@ export function pluginRoutes(
|
|||
* - 400 if job not found, not active, already running, or worker unavailable
|
||||
*/
|
||||
router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertInstanceAdmin(req);
|
||||
if (!jobDeps) {
|
||||
res.status(501).json({ error: "Job scheduling is not enabled" });
|
||||
return;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue