Files
PaperClipAI/server/src/routes/activity.ts
T
Devin FoleyandPaperclip 647116dd4b fix(adapter-utils): allow sandbox agents to read run diagnostics (#16575)
## Thinking Path

> - Paperclip manages agent work and its execution history.
> - Sandbox agents use a callback bridge to reach the control plane.
> - The API already provides authorized run diagnostics.
> - The bridge denied those read routes before the API could check
access.
> - Health scans could see task activity but could not inspect failed
runs.
> - This pull request admits exact GET routes on both bridge transports.
> - Linked task results also apply the caller's task-read filter.
> - Agents can inspect failures through their existing run identity.

## Linked Issues or Issue Description

**What happened?**

A sandbox health scan received `403 Route not allowed` for run history,
run details, events, and task recovery records. The same agent could
read task activity and connected provider state. The bridge stopped
these diagnostic requests before they reached the API.

**Expected behavior**

The bridge should admit the existing diagnostic GET routes. The API must
still enforce company and task access and redact run content. Execution
and recovery writes must stay denied by these rules.

**Steps to reproduce**

1. Start an agent in a sandbox with the normal callback bridge.
2. Use its injected API credential to request company run history.
3. Request a run's details or events.
4. Observe the bridge's route denial before the API handles the request.

**Paperclip version or commit**

Reproduced with the callback route list on master at `9f4a956b906`.

**Deployment mode**

Authenticated server with a managed sandbox. Both queue and HTTP/2
transports use this route list.

Related work: Refs #15854 adds separate cost-read routes to this list.
It does not add run diagnostics. Searches found no duplicate
run-diagnostic change.

## What Changed

- Admit nine existing diagnostic GET paths: company history and live
runs; run details, events, logs, workspace operations and task links;
task runs and recovery records.
- Reject other methods, extra path segments, execution control, recovery
resolution, and provider-trace administration.
- Filter linked tasks by the caller's task-read predicate in both
activity and original-task queries. Keep those results in the run's
company. A readable run must not expose private task titles.
- Test both route lists and the HTTP/2 request path. Preserve query
parameters and controller denial responses. Add a database regression
with public and private tasks linked to a readable company run.
- Document bounded diagnostic reads, private-run stubs, access checks,
and credential handling in the agent API reference. Update both affected
run access maps with the sandbox entry points and verification limits.

## Verification

- Passed both bridge suites: 139 tests.
- Passed the server run-route, activity-route, and secret-redaction
suites: 117 tests, including the new task-link filter test. The cold
route-import test needed a 30-second timeout on this host.
- Passed `pnpm -r typecheck`, `pnpm build`, the adapter-utils typecheck,
both generated capability checks, and `node scripts/check-maps.mjs`.
- Attempted `pnpm test:run`. The bundled PostgreSQL binary cannot start
on this host. The native session suite reported 15 database startup
failures; the privacy suite skipped its 15 cases. Stopped that full
local attempt after those failures. No full local-suite pass is claimed.
The final 16-case privacy suite also skipped locally for the same host
limitation; Linux CI ran all 16 cases successfully.
- One unchanged queue-response test hit a file-read race during the
combined local run. A separate rerun of both complete bridge suites
passed all 139 cases. The transient race was not independently isolated;
the original failure is retained in this record.
- All CI gates pass on head `97c65cec459d3fc2787083d05ae6ec51e3fd7b70`.
Linux CI passed all 16 database-backed run privacy cases, including the
new public/private task-link regression. These also cover private-run
denial, authorized owner/reviewer reads, redacted list stubs, credential
diagnostics, and deleted-task handling: [serialized shard
1](https://github.com/paperclipai/paperclip/actions/runs/38925932547/job/117057907621).
- Apex reviewed the final head at 5/5. Both findings are addressed and
their review threads are resolved.
- After deployment, run a new sandbox audit. Confirm it can list and
inspect authorized failed runs without a board credential. This has not
been deployed or live-tested by this PR.

## Risks

- Sandbox agents can reach more diagnostic GET routes. The existing
server access checks and redaction still apply.
- Linked task results now omit tasks that the caller cannot read.
Authorized task links remain visible.
- This change does not grant execution control or recover a failed
workspace.
- Existing bridge sessions may need a new run to load the updated route
list.
- No schema, credential, UI, workflow, or lockfile change.

## Model Used

OpenAI Codex, based on GPT-6, assisted with implementation and
verification. The exact serving model ID and context-window limit are
not exposed in this session. Capabilities used: code editing, shell
execution, and test execution.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-16 09:04:07 -07:00

386 lines
14 KiB
TypeScript

import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { normalizeIssueIdentifier } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { activityService, normalizeActivityLimit } from "../services/activity.js";
import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
import { accessService, heartbeatService, issueService } from "../services/index.js";
import { sanitizeRecord } from "../redaction.js";
import { badRequest, forbidden } from "../errors.js";
import { agentActionAuditService } from "../services/agent-action-audit.js";
import { logActivity } from "../services/activity-log.js";
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
import { issueReadSqlCondition } from "../services/authorization.js";
/** Max rows a single CSV export will stream (guards against runaway exports). */
const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000;
const AUDIT_CSV_PAGE_SIZE = 200;
const CSV_FORMULA_CHARS = /^[=+\-@\t\r]/;
const AUDIT_CSV_COLUMNS = [
"createdAt",
"action",
"actorType",
"actorId",
"agentId",
"runId",
"responsibleUserId",
"entityType",
"entityId",
"issueIdentifier",
"issueTitle",
"commentExcerpt",
"documentKey",
] as const;
function csvCell(value: unknown): string {
if (value === null || value === undefined) return "";
const str = value instanceof Date ? value.toISOString() : String(value);
// Prevent spreadsheet applications from interpreting user-controlled cells
// as formulas when an operator opens the export.
const safe = CSV_FORMULA_CHARS.test(str) ? `'${str}` : str;
// Quote if the value contains a delimiter, quote, or newline; escape quotes by doubling.
return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
}
function readNested(value: unknown, ...keys: string[]): string | null {
let cursor: unknown = value;
for (const key of keys) {
if (!cursor || typeof cursor !== "object") return null;
cursor = (cursor as Record<string, unknown>)[key];
}
return typeof cursor === "string" ? cursor : null;
}
type AuditCsvRow = {
createdAt: Date | string;
action: string;
actorType: string | null;
actorId: string | null;
agentId: string | null;
runId: string | null;
responsibleUserId: string | null;
entityType: string;
entityId: string;
// Enrichment snippet is redacted server-side into a plain record, so read it
// defensively rather than assuming a fixed shape.
entity: unknown;
};
function auditRowsToCsv(rows: AuditCsvRow[]): string {
const lines = [AUDIT_CSV_COLUMNS.join(",")];
for (const row of rows) {
lines.push([
csvCell(row.createdAt),
csvCell(row.action),
csvCell(row.actorType),
csvCell(row.actorId),
csvCell(row.agentId),
csvCell(row.runId),
csvCell(row.responsibleUserId),
csvCell(row.entityType),
csvCell(row.entityId),
csvCell(readNested(row.entity, "issue", "identifier")),
csvCell(readNested(row.entity, "issue", "title")),
csvCell(readNested(row.entity, "comment", "excerpt")),
csvCell(readNested(row.entity, "document", "key")),
].join(","));
}
// Trailing newline keeps POSIX tools + spreadsheet importers happy.
return `${lines.join("\r\n")}\r\n`;
}
const createActivitySchema = z.object({
actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"),
actorId: z.string().min(1),
action: z.string().min(1),
entityType: z.string().min(1),
entityId: z.string().min(1),
agentId: z.string().guid().optional().nullable(),
details: z.record(z.string(), z.unknown()).optional().nullable(),
});
const agentActionAuditActorScopeSchema = z.enum(["agents", "all"]);
const agentActionAuditQuerySchema = z.object({
actorScope: agentActionAuditActorScopeSchema.default("agents"),
agentId: z.string().guid().optional(),
responsibleUserId: z.string().min(1).optional(),
runId: z.string().guid().optional(),
entityType: z.string().min(1).optional(),
entityId: z.string().min(1).optional(),
action: z.string().min(1).optional(),
actorType: z.enum(["agent", "user", "system", "plugin"]).optional(),
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export function activityRoutes(db: Db) {
const router = Router();
const svc = activityService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const issueSvc = issueService(db);
const agentAudit = agentActionAuditService(db);
async function hasAgentAuditPermission(req: import("express").Request, companyId: string) {
if (req.actor.type !== "board") return false;
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
return Boolean(
req.actor.userId
&& await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions"),
);
}
async function assertAgentAuditPermission(req: import("express").Request, companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (await hasAgentAuditPermission(req, companyId)) return;
throw forbidden("Missing permission: audit:view_agent_actions");
}
function hasAttributionFilters(query: z.infer<typeof agentActionAuditQuerySchema>) {
return query.agentId !== undefined
|| query.responsibleUserId !== undefined
|| query.runId !== undefined
|| query.entityType !== undefined
|| query.entityId !== undefined
|| query.action !== undefined
|| query.actorType !== undefined
|| query.from !== undefined
|| query.to !== undefined;
}
function stripAuditAttribution<T extends {
items: Array<{
agentId: string | null;
runId: string | null;
responsibleUserId: string | null;
details: unknown;
}>;
}>(result: T): T {
return {
...result,
items: result.items.map((item) => ({
...item,
agentId: null,
runId: null,
responsibleUserId: null,
details: null,
})),
};
}
async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
const decision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (decision.allowed) return true;
res.status(403).json({ error: "Activity is outside this actor's authorization boundary" });
return false;
}
async function assertIssueReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, issue: {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
assigneeAgentId: string | null;
assigneeUserId: string | null;
status: string;
}) {
const decision = await access.decide({
actor: req.actor,
action: "issue:read",
resource: {
type: "issue",
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
status: issue.status,
},
});
if (decision.allowed) return true;
res.status(403).json({ error: "Issue activity is outside this actor's authorization boundary" });
return false;
}
async function resolveIssueByRef(rawId: string) {
const identifier = normalizeIssueIdentifier(rawId);
if (identifier) {
return issueSvc.getByIdentifier(identifier);
}
return issueSvc.getById(rawId);
}
router.get("/companies/:companyId/activity", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
const filters = {
companyId,
agentId: req.query.agentId as string | undefined,
entityType: req.query.entityType as string | undefined,
entityId: req.query.entityId as string | undefined,
limit: normalizeActivityLimit(Number(req.query.limit)),
readCondition: await issueReadSqlCondition(db, req.actor),
};
const result = await svc.list(filters);
res.json(result);
});
router.get("/companies/:companyId/audit/agent-actions", async (req, res) => {
const companyId = req.params.companyId as string;
const parsedActorScope = agentActionAuditActorScopeSchema.safeParse(req.query.actorScope ?? "agents");
if (!parsedActorScope.success) {
throw badRequest("Invalid agent action audit query", parsedActorScope.error.issues);
}
if (parsedActorScope.data === "agents") {
// Keep the legacy authorization-before-validation behavior for callers
// that omit the new flag.
await assertAgentAuditPermission(req, companyId);
} else {
assertCompanyAccess(req, companyId);
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
}
const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
}
if (parsedQuery.data.actorScope === "agents") {
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
res.json({ ...result, accessTier: "full" });
return;
}
const canViewAttribution = await hasAgentAuditPermission(req, companyId);
if (!canViewAttribution && hasAttributionFilters(parsedQuery.data)) {
throw forbidden("Audit filters require permission: audit:view_agent_actions");
}
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
res.json({
...(canViewAttribution ? result : stripAuditAttribution(result)),
accessTier: canViewAttribution ? "full" : "basic",
});
});
router.get("/companies/:companyId/audit/agent-actions.csv", async (req, res) => {
const companyId = req.params.companyId as string;
await assertAgentAuditPermission(req, companyId);
const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
}
// Drive our own pagination for the export; a client-supplied cursor/limit
// would silently truncate the export, so ignore them.
const { cursor: _cursor, limit: _limit, ...filters } = parsedQuery.data;
const rows: Awaited<ReturnType<typeof agentAudit.list>>["items"] = [];
let cursor: string | undefined;
do {
const page = await agentAudit.list({ companyId, ...filters, cursor, limit: AUDIT_CSV_PAGE_SIZE });
for (const item of page.items) {
if (rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS) break;
rows.push(item);
}
cursor = page.nextCursor ?? undefined;
} while (cursor && rows.length < AUDIT_CSV_EXPORT_MAX_ROWS);
// The export is itself an auditable act (training-data export precedent):
// record who exported what filter set and how many rows left the system.
const actorUserId = req.actor.type === "board" ? req.actor.userId ?? null : null;
await logActivity(db, {
companyId,
actorType: actorUserId ? "user" : "system",
actorId: actorUserId ?? "local-board",
action: "audit.exported",
entityType: "company",
entityId: companyId,
details: {
format: "csv",
rowCount: rows.length,
truncated: rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS && Boolean(cursor),
filters: {
actorScope: filters.actorScope,
agentId: filters.agentId ?? null,
responsibleUserId: filters.responsibleUserId ?? null,
runId: filters.runId ?? null,
entityType: filters.entityType ?? null,
entityId: filters.entityId ?? null,
action: filters.action ?? null,
actorType: filters.actorType ?? null,
from: filters.from ? filters.from.toISOString() : null,
to: filters.to ? filters.to.toISOString() : null,
},
},
});
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="agent-audit-${companyId}.csv"`);
res.send(auditRowsToCsv(rows));
});
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const event = await svc.create({
companyId,
...req.body,
details: req.body.details ? sanitizeRecord(req.body.details) : null,
});
res.status(201).json(event);
});
router.get("/issues/:id/activity", async (req, res) => {
const rawId = req.params.id as string;
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const result = await svc.forIssue(issue.id);
res.json(result);
});
router.get("/issues/:id/runs", async (req, res) => {
const rawId = req.params.id as string;
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const result = await svc.runsForIssue(issue.companyId, issue.id,
req.actor.type === "board" ? getActorInfo(req).actorId : null);
res.json(result);
});
router.get("/heartbeat-runs/:runId/issues", async (req, res) => {
assertAuthenticated(req);
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run || !hasCompanyAccess(req, run.companyId)) {
// Return `200 []` for both "doesn't exist" and "cross-tenant" — preserves the
// legacy API contract while keeping the cross-tenant existence oracle closed
// (both branches yield indistinguishable responses).
res.json([]);
return;
}
assertCompanyAccess(req, run.companyId);
if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return;
if (!(await canActorReadHeartbeatRun(db, access, req.actor, run))) {
res.json([]);
return;
}
const result = await svc.issuesForRun(runId, await issueReadSqlCondition(db, req.actor));
res.json(result);
});
return router;
}