mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] harden authenticated routes and issue editor reliability (#3741)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The control plane depends on authenticated routes enforcing company boundaries and role permissions correctly > - This branch also touches the issue detail and markdown editing flows operators use while handling advisory and triage work > - Partial issue cache seeds and fragile rich-editor parsing could leave important issue content missing or blank at the moment an operator needed it > - Blocked issues becoming actionable again should wake their assignee automatically instead of silently staying idle > - This pull request rebases the advisory follow-up branch onto current `master`, hardens authenticated route authorization, and carries the issue-detail/editor reliability fixes forward with regression tests > - The benefit is tighter authz on sensitive routes plus more reliable issue/advisory editing and wakeup behavior on top of the latest base ## What Changed - Hardened authenticated route authorization across agent, activity, approval, access, project, plugin, health, execution-workspace, portability, and related server paths, with new cross-tenant and runtime-authz regression coverage. - Switched issue detail queries from `initialData` to placeholder-based hydration so list/quicklook seeds still refetch full issue bodies. - Normalized advisory-style HTML images before mounting the markdown editor and strengthened fallback behavior when the rich editor silently fails or rejects the content. - Woke assigned agents when blocked issues move back to `todo`, with route coverage for reopen and unblock transitions. - Rebasing note: this branch now sits cleanly on top of the latest `master` tip used for the PR base. ## Verification - `pnpm exec vitest run ui/src/lib/issueDetailQuery.test.tsx ui/src/components/MarkdownEditor.test.tsx server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/activity-routes.test.ts server/src/__tests__/agent-cross-tenant-authz-routes.test.ts` - Confirmed `pnpm-lock.yaml` is not part of the PR diff. - Rebased the branch onto current `public-gh/master` before publishing. ## Risks - Broad authz tightening may expose existing flows that were relying on permissive board or agent access and now need explicit grants. - Markdown editor fallback changes could affect focus or rendering in edge-case content that mixes HTML-like advisory markup with normal markdown. - This verification was intentionally scoped to touched regressions and did not run the full repository suite. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment with tool use for terminal, git, and GitHub operations. The exact runtime model identifier is not exposed inside this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, it is behavior-only and does not need before/after screenshots - [x] I have updated relevant documentation to reflect my changes, or no documentation changes were needed for these internal fixes - [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
50cd76d8a3
commit
32a9165ddf
39 changed files with 3014 additions and 153 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { Router, type Request } from "express";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
|
@ -46,6 +46,10 @@ import {
|
|||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
collectAgentAdapterWorkspaceCommandPaths,
|
||||
} from "./workspace-command-authz.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
findActiveServerAdapter,
|
||||
|
|
@ -231,10 +235,33 @@ export function agentRoutes(db: Db) {
|
|||
return actorAgent;
|
||||
}
|
||||
|
||||
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
|
||||
if (!allowed) {
|
||||
throw forbidden("Missing permission: agents:create");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCanReadConfigurations(req: Request, companyId: string) {
|
||||
return assertCanCreateAgentsForCompany(req, companyId);
|
||||
}
|
||||
|
||||
async function getAccessibleAgent(req: Request, res: Response, id: string) {
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return null;
|
||||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
if (req.actor.type === "board") {
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "board") {
|
||||
|
|
@ -317,7 +344,10 @@ export function agentRoutes(db: Db) {
|
|||
|
||||
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
if (req.actor.type === "board") {
|
||||
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
|
||||
return;
|
||||
}
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
|
|
@ -339,7 +369,10 @@ export function agentRoutes(db: Db) {
|
|||
|
||||
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
if (req.actor.type === "board") {
|
||||
await assertCanReadConfigurations(req, targetAgent.companyId);
|
||||
return;
|
||||
}
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
|
|
@ -610,19 +643,24 @@ export function agentRoutes(db: Db) {
|
|||
|
||||
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden(
|
||||
"Only board-authenticated callers can manage instructions path or bundle configuration",
|
||||
);
|
||||
}
|
||||
if (actorAgent.id === targetAgent.id) return;
|
||||
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
|
||||
}
|
||||
|
||||
const chainOfCommand = await svc.getChainOfCommand(targetAgent.id);
|
||||
if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return;
|
||||
|
||||
throw forbidden("Only the target agent or an ancestor manager can update instructions path");
|
||||
function assertNoAgentInstructionsConfigMutation(
|
||||
req: Request,
|
||||
adapterConfig: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
if (req.actor.type !== "agent" || !adapterConfig) return;
|
||||
const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined);
|
||||
if (changedSensitiveKeys.length === 0) return;
|
||||
throw forbidden(
|
||||
`Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
|
||||
|
|
@ -997,7 +1035,7 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
const result = await svc.list(companyId);
|
||||
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
|
||||
if (canReadConfigs || req.actor.type === "board") {
|
||||
if (canReadConfigs) {
|
||||
res.json(result);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1173,12 +1211,13 @@ export function agentRoutes(db: Db) {
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
||||
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
|
||||
if (!canRead) {
|
||||
res.json(await buildAgentDetail(agent, { restricted: true }));
|
||||
return;
|
||||
}
|
||||
const isSelf = req.actor.type === "agent" && req.actor.agentId === id;
|
||||
const canReadSensitiveDetail = isSelf
|
||||
? true
|
||||
: await actorCanReadConfigurationsForCompany(req, agent.companyId);
|
||||
if (!canReadSensitiveDetail) {
|
||||
res.json(await buildAgentDetail(agent, { restricted: true }));
|
||||
return;
|
||||
}
|
||||
res.json(await buildAgentDetail(agent));
|
||||
});
|
||||
|
|
@ -1266,6 +1305,7 @@ export function agentRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
const state = await heartbeat.getRuntimeState(id);
|
||||
|
|
@ -1280,6 +1320,7 @@ export function agentRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
const sessions = await heartbeat.listTaskSessions(id);
|
||||
|
|
@ -1299,6 +1340,7 @@ export function agentRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
const taskKey =
|
||||
|
|
@ -1331,6 +1373,14 @@ export function agentRoutes(db: Db) {
|
|||
...hireInput
|
||||
} = req.body;
|
||||
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig),
|
||||
);
|
||||
assertNoAgentInstructionsConfigMutation(
|
||||
req,
|
||||
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
hireInput.adapterType,
|
||||
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
|
|
@ -1590,6 +1640,8 @@ export function agentRoutes(db: Db) {
|
|||
res.status(403).json({ error: "Only CEO can manage permissions" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await assertBoardCanManageAgentsForCompany(req, existing.companyId);
|
||||
}
|
||||
|
||||
const agent = await svc.updatePermissions(id, req.body);
|
||||
|
|
@ -1890,10 +1942,15 @@ export function agentRoutes(db: Db) {
|
|||
res.status(422).json({ error: "adapterConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
const changingInstructionsPath = Object.keys(adapterConfig).some((key) =>
|
||||
KNOWN_INSTRUCTIONS_PATH_KEYS.has(key),
|
||||
assertNoAgentInstructionsConfigMutation(req, adapterConfig);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(adapterConfig),
|
||||
);
|
||||
if (changingInstructionsPath) {
|
||||
const changingInstructionsConfig = Object.keys(adapterConfig).some((key) =>
|
||||
KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]),
|
||||
);
|
||||
if (changingInstructionsConfig) {
|
||||
await assertCanManageInstructionsPath(req, existing);
|
||||
}
|
||||
patchData.adapterConfig = adapterConfig;
|
||||
|
|
@ -1994,6 +2051,9 @@ export function agentRoutes(db: Db) {
|
|||
router.post("/agents/:id/pause", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.pause(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -2017,6 +2077,9 @@ export function agentRoutes(db: Db) {
|
|||
router.post("/agents/:id/resume", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.resume(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -2038,6 +2101,9 @@ export function agentRoutes(db: Db) {
|
|||
router.post("/agents/:id/terminate", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.terminate(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -2061,6 +2127,9 @@ export function agentRoutes(db: Db) {
|
|||
router.delete("/agents/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.remove(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -2082,6 +2151,10 @@ export function agentRoutes(db: Db) {
|
|||
router.get("/agents/:id/keys", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const agent = await getAccessibleAgent(req, res, id);
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const keys = await svc.listKeys(id);
|
||||
res.json(keys);
|
||||
});
|
||||
|
|
@ -2089,32 +2162,56 @@ export function agentRoutes(db: Db) {
|
|||
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const agent = await getAccessibleAgent(req, res, id);
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const key = await svc.createApiKey(id, req.body.name);
|
||||
|
||||
const agent = await svc.getById(id);
|
||||
if (agent) {
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "agent.key_created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name },
|
||||
});
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "agent.key_created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name },
|
||||
});
|
||||
|
||||
res.status(201).json(key);
|
||||
});
|
||||
|
||||
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const keyId = req.params.keyId as string;
|
||||
const revoked = await svc.revokeKey(keyId);
|
||||
const agent = await getAccessibleAgent(req, res, id);
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await svc.getKeyById(keyId);
|
||||
if (!key || key.agentId !== agent.id) {
|
||||
res.status(404).json({ error: "Key not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const revoked = await svc.revokeKey(agent.id, keyId);
|
||||
if (!revoked) {
|
||||
res.status(404).json({ error: "Key not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "agent.key_revoked",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name },
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
|
@ -2127,9 +2224,13 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
||||
res.status(403).json({ error: "Agent can only invoke itself" });
|
||||
return;
|
||||
if (req.actor.type === "agent") {
|
||||
if (req.actor.agentId !== id) {
|
||||
res.status(403).json({ error: "Agent can only invoke itself" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
|
||||
const run = await heartbeat.wakeup(id, {
|
||||
|
|
@ -2177,9 +2278,13 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
||||
res.status(403).json({ error: "Agent can only invoke itself" });
|
||||
return;
|
||||
if (req.actor.type === "agent") {
|
||||
if (req.actor.agentId !== id) {
|
||||
res.status(403).json({ error: "Agent can only invoke itself" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
|
||||
const run = await heartbeat.invoke(
|
||||
|
|
@ -2225,6 +2330,7 @@ export function agentRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
if (agent.adapterType !== "claude_local") {
|
||||
res.status(400).json({ error: "Login is only supported for claude_local agents" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue