diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 13067391ef2..1048f886509 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -891,6 +891,15 @@ grant privileged tools. Backlog assignments retain the existing human requester
without starting a run. Reassignment transactionally cancels prior human requests,
including service/plugin writes; cancelled runs cannot authorize later retries. See `doc/LOW-TRUST-PRESETS.md` for containment details.
+Project or repository access is a separate boundary from exact-task direction.
+A workspace project denial appears on the task with a link to the agent's
+Permissions / Trust settings. Board operators with `agents:create` can add
+same-company projects through `POST /api/agents/:id/project-access`; the operation
+locks the agent, preserves existing permissions and audits added projects.
+Saving does not retry work. The task offers explicit Retry after the required
+project is allowed, and dispatch rechecks authorization. Existing setup failures
+with the exact project-boundary denial also receive the explanation on reload.
+
Cross-issue writes are contained per heartbeat run. An agent-authored comment
may wake the target assignee, including an explicit `resume: true` comment on a
`done` or `cancelled` issue, but the wake remains agent-class and is subject to
diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md
index efc593ec743..1fa58307434 100644
--- a/maps/agent-access-map/agents.md
+++ b/maps/agent-access-map/agents.md
@@ -104,6 +104,12 @@ Review: `baseline`.
API: `present` — representative operation IDs:
- `GET /api/agents/{id}/instructions-bundle/file`.
+- `GET /api/agents/{id}/project-access` and `POST /api/agents/{id}/project-access`
+ are board-only. The POST requires `agents:create`, checks selected projects in
+ the same company, and adds IDs under an agent row lock. It preserves existing
+ containment, audits new grants and rejects pending-approval agents. Agent keys
+ cannot use this route to grant themselves access; a read-only board viewer
+ receives `canManage: false`. Saving never creates a run or wakeup.
- `PUT /api/agents/{id}/instructions-bundle/file`.
- `PATCH /api/agents/{id}` with an explicit `runner: "paperclip"` upgrade.
- `POST /api/agents/{id}/qualify-runner` to qualify the saved effective sandbox before upgrading.
diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md
index 95fed092ec1..bb3b705cd1d 100644
--- a/maps/feature-map/agents.md
+++ b/maps/feature-map/agents.md
@@ -41,6 +41,12 @@ existing preparation and verification lifecycle.
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
+For a low-trust agent, open **Permissions / Trust → Allowed projects**. An operator
+with agent-management permission can select additional projects and explicitly
+save them. Existing projects and other containment restrictions remain intact.
+Cancel discards the draft; a failed save retains it. Reload to verify persistence.
+This does not start or retry a task.
+
In `/agents/all`, select roster checkboxes and choose **Edit connection & model**.
A mixed selection offers one action per harness. Each field is opt-in. The editor
previews each changed value and excludes agents already at the target value.
diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md
index 4294a1876a3..6624fbc67f7 100644
--- a/maps/feature-map/recovery.md
+++ b/maps/feature-map/recovery.md
@@ -48,6 +48,12 @@ Open the affected task from Tasks, Inbox/Blocked, search, or a direct task link.
Observe one working, backoff, failed or waiting-for-user state. Use Retry after
an infrastructure failure. No process or file certification is required.
+A workspace project denial names the blocked access and links to the agent's
+**Permissions / Trust** settings. After an authorized person adds the required
+project, return to the saved task for an explicit **Retry task** action. Saving
+permissions alone does not wake the agent. Historical setup denials receive the
+same notice on reload; unrelated errors and prior assignees do not.
+
Thinking appears immediately after Retry, before the request completes.
Technical source/run details remain in deliberate diagnostics. The
[task recovery contract](../../doc/task-recovery.md) describes receipt identity,
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 99f0a885507..a45052d7fe0 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -889,6 +889,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
+ AgentProjectAccess,
AgentPublicIdentity,
AgentInstructionsBundleMode,
AgentInstructionsFileSummary,
@@ -1882,6 +1883,7 @@ export {
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
+ addAgentProjectAccessSchema,
type CreateAgent,
type BuiltInAgentProvision,
type BuiltInAgentReset,
diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts
index ac1970305b7..ba4b594389b 100644
--- a/packages/shared/src/types/agent.ts
+++ b/packages/shared/src/types/agent.ts
@@ -25,6 +25,11 @@ export interface AgentPermissions extends Record {
authorizationPolicy?: TrustAuthorizationPolicy;
}
+export interface AgentProjectAccess {
+ projectIds: string[];
+ canManage: boolean;
+}
+
export type AgentRuntimeConfig = Record & {
aiConnection?: import("../ai-connection-router.js").AiRuntimeConnectionBinding;
};
diff --git a/packages/shared/src/types/execution-projection.ts b/packages/shared/src/types/execution-projection.ts
index 23c7d3d688d..ff28fd01dc1 100644
--- a/packages/shared/src/types/execution-projection.ts
+++ b/packages/shared/src/types/execution-projection.ts
@@ -104,7 +104,9 @@ export interface TaskInterruptionPresentation {
state: "working" | "retry_scheduled" | "failed" | "waiting_for_user" | "resolved";
nextRetryAt: string | null;
canRetry: boolean;
- userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | null;
+ userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | "project_access" | null;
+ /** Only the task's own project id is exposed. Other source projects stay unnamed. */
+ projectAccess?: { agentId: string; projectId: string | null; ready: boolean };
sourceRunIds: string[];
observedRunId: string | null;
}
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index c89aa62b1e3..b10aa2c33ea 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -280,6 +280,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
+ AgentProjectAccess,
AgentPublicIdentity,
AgentRuntimeConfig,
AgentInstructionsBundleMode,
diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts
index 32d65555e6d..1e9e42e2876 100644
--- a/packages/shared/src/validators/agent.ts
+++ b/packages/shared/src/validators/agent.ts
@@ -322,6 +322,10 @@ export const updateAgentPermissionsSchema = z.object({
export type UpdateAgentPermissions = z.infer;
+export const addAgentProjectAccessSchema = z.object({
+ projectIds: z.array(z.string().uuid()).min(1).max(100),
+}).strict();
+
export const resolveAgentInstructionCandidateSchema = z.object({
baseRevisionId: z.string().uuid().nullable(),
content: z.string().max(1024 * 1024),
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index 05519dd262f..8f1ba1bf907 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -370,6 +370,7 @@ export {
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
+ addAgentProjectAccessSchema,
type CreateAgent,
type BuiltInAgentProvision,
type BuiltInAgentReset,
diff --git a/server/src/__tests__/agent-project-access.test.ts b/server/src/__tests__/agent-project-access.test.ts
new file mode 100644
index 00000000000..38618a3669c
--- /dev/null
+++ b/server/src/__tests__/agent-project-access.test.ts
@@ -0,0 +1,107 @@
+import { randomUUID } from "node:crypto";
+import express from "express";
+import request from "supertest";
+import { and, eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { agents, agentWakeupRequests, activityLog, authUsers, companies, companyMemberships, createDb,
+ heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
+import { registerAgentProjectAccessRoutes } from "../routes/agent-project-access.js";
+import { errorHandler } from "../middleware/error-handler.js";
+import { assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js";
+import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js";
+import { ProjectAccessDeniedError } from "../services/project-access-denial.js";
+import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+
+describe("additive project access", () => {
+ let database: Awaited> | undefined;
+ let db: ReturnType;
+ beforeAll(async () => {
+ const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
+ if (external) db = createDb(external);
+ else { database = await startEmbeddedPostgresTestDatabase("agent-project-access-"); db = createDb(database.connectionString); }
+ }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
+ afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
+
+ async function seed() {
+ const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Project access", issuePrefix: `A${companyId.slice(0, 6)}` });
+ const selected = await db.insert(projects).values(["Existing", "Needed", "Third"].map(name => ({ companyId, name }))).returning();
+ const permissions = { canCreateAgents: false, canCreateSkills: false, trustPreset: "low_trust_review",
+ authorizationPolicy: { reviewPreset: { id: "low_trust_review", version: 1, rawOutputDisposition: "quarantine" },
+ customPolicy: { preserve: true }, trustBoundary: { mode: "low_trust_review", companyId,
+ projectIds: [selected[0]!.id], issueIds: [issueId], allowedToolClasses: ["git.read"], allowedSecretBindingIds: [] } } };
+ await db.insert(agents).values({ id: agentId, companyId, name: "Reviewer", adapterType: "process", status: "idle", permissions });
+ await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" });
+ const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Saved message", status: "blocked", assigneeAgentId: agentId, projectId: selected[1]!.id }).returning();
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "failed", errorCode: "setup_failed",
+ error: "Project is outside this low-trust boundary.", contextSnapshot: { issueId } });
+ return { companyId, agentId, issue: issue!, runId, selected, permissions };
+ }
+ function app(actor: express.Request["actor"]) {
+ const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); });
+ registerAgentProjectAccessRoutes(app, db); app.use(errorHandler); return app;
+ }
+ const board = { type: "board" as const, source: "local_implicit" as const };
+
+ it("preserves containment, audits concurrent additions, and changes the persisted denial into an explicit retry", async () => {
+ const f = await seed();
+ const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId };
+ await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).rejects.toBeInstanceOf(ProjectAccessDeniedError);
+ expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ userAction: "project_access", canRetry: false });
+ const api = app(board);
+ const results = await Promise.all(f.selected.slice(1).map(project => request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [project.id] })));
+ expect(results.map(result => result.status)).toEqual([200, 200]);
+ const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
+ expect(saved!.permissions).toEqual({ ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy,
+ trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, projectIds: expect.arrayContaining(f.selected.map(project => project.id)) } } });
+ expect((saved!.permissions as typeof f.permissions).authorizationPolicy.trustBoundary.projectIds).toHaveLength(3);
+ await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).resolves.toBeUndefined();
+ expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
+ const again = await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] });
+ expect(again.status).toBe(200);
+ expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "agent.project_access_added")))).toHaveLength(2);
+ expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
+ expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
+ });
+
+ it("rejects foreign projects, agent self-grants, unknown policy fields, and pending-approval agents", async () => {
+ const f = await seed(), other = await seed(), api = app(board);
+ expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [other.selected[1]!.id] })).status).toBe(404);
+ expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id], trustPreset: "standard" })).status).toBe(400);
+ expect((await request(app({ type: "agent", agentId: f.agentId, companyId: f.companyId, source: "agent_key" }))
+ .post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403);
+ await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId));
+ expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(409);
+ const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); expect(saved!.permissions).toEqual(f.permissions);
+ });
+
+ it.each(["standard", "root_issue"])("rechecks actual project authority after a %s repair and acknowledges an accepted retry", async repair => {
+ const f = await seed();
+ const permissions = repair === "standard" ? { canCreateAgents: false, trustPreset: "standard", authorizationPolicy: {} }
+ : { ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy,
+ trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, rootIssueId: f.issue.id } } };
+ await db.update(agents).set({ permissions }).where(eq(agents.id, f.agentId));
+ expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
+ await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agentId,
+ source: "on_demand", status: "deferred_issue_execution", requestedByActorType: "user", payload: { issueId: f.issue.id } });
+ const accepted = await taskInterruptionPresentation(db, f.issue);
+ expect(accepted).toMatchObject({ state: "working", canRetry: false });
+ expect(accepted).not.toHaveProperty("projectAccess");
+ // Current project policy remains authoritative even after agent access changes.
+ await db.update(projects).set({ executionWorkspacePolicy: { trustPreset: "low_trust_review", authorizationPolicy: {
+ trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.selected[0]!.id] },
+ } } }).where(eq(projects.id, f.selected[1]!.id));
+ if (repair === "standard") expect(await taskInterruptionPresentation(db, f.issue))
+ .toMatchObject({ canRetry: false, userAction: "project_access" });
+ });
+
+ it("shows read-only access to viewers and denies their saves and cross-company reads", async () => {
+ const f = await seed(), other = await seed(), userId = randomUUID();
+ await db.insert(authUsers).values({ id: userId, name: "Viewer", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
+ await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "viewer", status: "active" });
+ const api = app({ type: "board", source: "session", userId, companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "viewer", status: "active" }] });
+ expect((await request(api).get(`/agents/${f.agentId}/project-access`)).body).toMatchObject({ canManage: false });
+ expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403);
+ expect((await request(api).get(`/agents/${other.agentId}/project-access`)).status).toBe(404);
+ });
+});
diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts
index d0abe4e4b4b..ff43e560394 100644
--- a/server/src/__tests__/openapi-routes.test.ts
+++ b/server/src/__tests__/openapi-routes.test.ts
@@ -21,6 +21,7 @@ const apiPrefixes: Record = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
+ "agent-project-access.ts": "/api",
"agent-commentary.ts": "/api",
"agent-avatars.ts": "/api",
"agent-profile-avatar.ts": "/api",
@@ -987,6 +988,21 @@ describe("openapi routes", () => {
});
});
+ it("documents additive project access as board-only with a strict request", () => {
+ const { spec } = loadSpecRoutes();
+ const path = spec.paths["/api/agents/{id}/project-access"];
+ for (const operation of [path.get, path.post]) {
+ expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
+ expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
+ expect(operation.responses["200"].content["application/json"].schema.required).toEqual(["projectIds", "canManage"]);
+ }
+ expect(path.post.requestBody.content["application/json"].schema).toMatchObject({
+ additionalProperties: false, required: ["projectIds"],
+ properties: { projectIds: { type: "array", items: { type: "string", format: "uuid" } } },
+ });
+ expect(path.post.responses["409"]).toBeDefined();
+ });
+
it("documents the authenticated personal primary-agent contract", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/companies/{companyId}/primary-agent/me"];
diff --git a/server/src/routes/agent-project-access.ts b/server/src/routes/agent-project-access.ts
new file mode 100644
index 00000000000..08346180aab
--- /dev/null
+++ b/server/src/routes/agent-project-access.ts
@@ -0,0 +1,25 @@
+import type { Router } from "express";
+import { agents, type Db } from "@paperclipai/db";
+import { eq } from "drizzle-orm";
+import { addAgentProjectAccessSchema } from "@paperclipai/shared";
+import { notFound } from "../errors.js";
+import { validate } from "../middleware/validate.js";
+import { addAgentProjectAccess, readAgentProjectAccess } from "../services/agent-project-access.js";
+import { assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js";
+
+export function registerAgentProjectAccessRoutes(router: Router, db: Db) {
+ router.get("/agents/:id/project-access", async (req, res) => {
+ assertBoard(req);
+ const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string));
+ if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
+ assertCompanyAccess(req, agent.companyId);
+ res.json(await readAgentProjectAccess(db, req.actor, agent.companyId, agent.id));
+ });
+ router.post("/agents/:id/project-access", validate(addAgentProjectAccessSchema), async (req, res) => {
+ assertBoard(req);
+ const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string));
+ if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
+ assertCompanyAccess(req, agent.companyId);
+ res.json(await addAgentProjectAccess(db, req.actor, agent.companyId, agent.id, req.body.projectIds));
+ });
+}
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 540e1f7d8cc..0aba86abb8f 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -98,6 +98,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { trackAgentCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
+import { registerAgentProjectAccessRoutes } from "./agent-project-access.js";
import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
@@ -4918,6 +4919,8 @@ export function agentRoutes(
res.status(201).json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) });
});
+ registerAgentProjectAccessRoutes(router, db);
+
router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 650eb0670eb..bbb50ac7f55 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -48,6 +48,7 @@ import {
updateAgentSchema,
updatePrimaryAgentSchema,
updateAgentPermissionsSchema,
+ addAgentProjectAccessSchema,
updateAgentInstructionsPathSchema,
updateAgentInstructionsBundleSchema,
upsertAgentInstructionsFileSchema,
@@ -1390,6 +1391,8 @@ const browserUseOperations = [
] as const;
const BOARD_ONLY_OPERATIONS = new Set([
+ "GET /api/agents/{id}/project-access",
+ "POST /api/agents/{id}/project-access",
"POST /api/issues/{id}/interactions/{interactionId}/dismiss",
"POST /api/issues/{id}/retry",
"GET /api/companies/{companyId}/prompt-injection-prevention",
@@ -3790,6 +3793,26 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
+const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() });
+registry.registerPath({
+ method: "get",
+ path: "/api/agents/{id}/project-access",
+ tags: ["agents"],
+ summary: "Read an agent's allowed projects and the current operator's management capability",
+ request: { params: z.object({ id: z.string() }) },
+ responses: { 200: r.ok(agentProjectAccessResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+registry.registerPath({
+ method: "post",
+ path: "/api/agents/{id}/project-access",
+ tags: ["agents"],
+ summary: "Add allowed projects without replacing an agent's other permissions",
+ description: "Requires board access and agents:create in the agent's company. Accepts 1 to 100 project IDs. Selected projects must be readable and in that company. Locks the low-trust agent, preserves existing containment, audits new additions, and rejects pending-approval agents or changed trust settings. Repeated additions are idempotent. Saving does not start or retry a task.",
+ request: { params: z.object({ id: z.string() }), body: jsonBody(addAgentProjectAccessSchema) },
+ responses: { 200: r.ok(agentProjectAccessResponse), 400: r.badRequest, 401: r.unauthorized,
+ 403: r.forbidden, 404: r.notFound, 409: r.conflict },
+});
+
registry.registerPath({
method: "patch",
path: "/api/agents/{id}/instructions-path",
diff --git a/server/src/services/agent-project-access.ts b/server/src/services/agent-project-access.ts
new file mode 100644
index 00000000000..4c6ee81659a
--- /dev/null
+++ b/server/src/services/agent-project-access.ts
@@ -0,0 +1,69 @@
+import { and, eq, inArray } from "drizzle-orm";
+import { agents, projects, type Db } from "@paperclipai/db";
+import { trustAuthorizationPolicySchema, type AgentProjectAccess } from "@paperclipai/shared";
+import { conflict, forbidden, notFound } from "../errors.js";
+import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
+import { accessService } from "./access.js";
+import type { AuthorizationActor } from "./authorization.js";
+import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
+
+export async function readAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string): Promise {
+ const [agent] = await db.select({ permissions: agents.permissions, status: agents.status }).from(agents)
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
+ if (!agent) throw notFound("Agent not found");
+ const decision = await accessService(db).decide({ actor, action: "agents:create", resource: { type: "company", companyId } });
+ const permissions = normalizeAgentPermissions(agent.permissions);
+ const policy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {});
+ const boundary = policy.success ? policy.data.trustBoundary : undefined;
+ return { projectIds: policy.success ? policy.data.trustBoundary?.projectIds ?? [] : [],
+ canManage: actor.type === "board" && decision.allowed && agent.status !== "pending_approval"
+ && policy.success && permissions.trustPreset === "low_trust_review"
+ && (!boundary || (boundary.mode === "low_trust_review" && (!boundary.companyId || boundary.companyId === companyId))) };
+}
+
+/** An additive operation under the agent row lock, never a stale policy replacement. */
+export async function addAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string, projectIds: string[]) {
+ if (actor.type !== "board") throw forbidden("Board access required");
+ const publications: ActivityPublication[] = [];
+ const result = await db.transaction(async tx => {
+ const [agent] = await tx.select().from(agents)
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update");
+ if (!agent) throw notFound("Agent not found");
+ const transactionDb = tx as unknown as Db;
+ const access = accessService(transactionDb);
+ const decision = await access.decide({ actor, action: "agents:create", resource: { type: "company", companyId } });
+ if (!decision.allowed) throw forbidden(decision.explanation);
+ if (agent.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval");
+ const permissions = normalizeAgentPermissions(agent.permissions);
+ const parsedPolicy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {});
+ if (!parsedPolicy.success) throw conflict("The agent’s trust settings need review before adding project access.");
+ const policy = parsedPolicy.data;
+ const boundary = policy?.trustBoundary;
+ if (permissions.trustPreset !== "low_trust_review" || (boundary && (boundary.mode !== "low_trust_review"
+ || (boundary.companyId && boundary.companyId !== companyId)))) {
+ throw conflict("The agent’s trust settings changed. Refresh before adding project access.");
+ }
+ const requested = [...new Set(projectIds)];
+ const rows = await tx.select({ id: projects.id }).from(projects)
+ .where(and(eq(projects.companyId, companyId), inArray(projects.id, requested)));
+ if (rows.length !== requested.length) throw notFound("A selected project is unavailable");
+ for (const project of rows) {
+ const projectDecision = await access.decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId: project.id } });
+ if (!projectDecision.allowed) throw forbidden("A selected project is unavailable");
+ }
+ const previous = boundary?.projectIds ?? [];
+ const added = requested.filter(id => !previous.includes(id));
+ const next = [...previous, ...added];
+ if (added.length) {
+ await tx.update(agents).set({ permissions: { ...agent.permissions, authorizationPolicy: {
+ ...policy, trustBoundary: { ...boundary, mode: "low_trust_review", companyId, projectIds: next },
+ } }, updatedAt: new Date() }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)));
+ await logActivity(transactionDb, { companyId, actorType: "user", actorId: actor.userId ?? "local-board",
+ action: "agent.project_access_added", entityType: "agent", entityId: agentId,
+ details: { addedProjectIds: added, trustPreset: "low_trust_review" } }, publications);
+ }
+ return { projectIds: next, canManage: true } satisfies AgentProjectAccess;
+ });
+ for (const publication of publications) publishActivity(publication);
+ return result;
+}
diff --git a/server/src/services/heartbeat/run-completion.test.ts b/server/src/services/heartbeat/run-completion.test.ts
index 39df53e1d52..1dc15b06e9c 100644
--- a/server/src/services/heartbeat/run-completion.test.ts
+++ b/server/src/services/heartbeat/run-completion.test.ts
@@ -9,6 +9,7 @@ import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
import { UnresolvedWorkspaceBaseRefError } from "../workspace-runtime.js";
import { LegacyControllerLeaseLostError } from "../legacy-controller-lease.js";
import { requestedRunCancellation } from "../run-cancellation.js";
+import { ProjectAccessDeniedError } from "../project-access-denial.js";
import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "./run-lifecycle.js";
import { createHeartbeatRunPreparation, ConfigurationIncompleteFailure } from "./run-preparation.js";
import { createHeartbeatRunState, getAdapterSessionCodec } from "./run-state.js";
@@ -77,15 +78,19 @@ it("constructs completion handlers without querying or starting work", () => {
});
const support = await getEmbeddedPostgresTestSupport();
-describe.skipIf(!support.supported)("heartbeat run completion boundary", () => {
- let database: Awaited>;
+describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)("heartbeat run completion boundary", () => {
+ let database: Awaited> | undefined;
let db: Db;
beforeAll(async () => {
// Keep the accounting transaction real without reading provider receipt files.
vi.spyOn(usageReceipts, "indexPendingUsageReceipts").mockResolvedValue(new Map());
vi.spyOn(usageReceipts, "recoverPendingRunUsageReceipts").mockResolvedValue([]);
- database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion");
- db = createDb(database.connectionString);
+ const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
+ if (external) db = createDb(external);
+ else {
+ database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion");
+ db = createDb(database.connectionString);
+ }
await db.execute(sql`set client_min_messages = warning`);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); vi.restoreAllMocks(); });
@@ -334,6 +339,18 @@ describe.skipIf(!support.supported)("heartbeat run completion boundary", () => {
expect(f.deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(expect.objectContaining({ id: f.run.id }), { suppressImmediateRecovery: true });
});
+ it("persists a project denial without provider work or an automatic retry", async () => {
+ const f = await fixture();
+ const projectId = randomUUID();
+ await f.completion.failRunSetup(f.setup(new ProjectAccessDeniedError(projectId)));
+ expect(await read(f.run.id)).toMatchObject({ status: "failed", errorCode: "project_access_denied",
+ resultJson: { projectAccessDenied: { projectId }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } });
+ expect(f.deps.scheduleBoundedRetryForRun).not.toHaveBeenCalled();
+ expect(f.deps.enqueueWakeup).not.toHaveBeenCalled();
+ expect(f.deps.scheduleInteractionContinuationInfrastructureRetryIfEligible).not.toHaveBeenCalled();
+ expect(f.deps.finalizeIssueCommentPolicy).not.toHaveBeenCalled();
+ });
+
it("records a setup configuration gap without inventing provider work or applying comment policy", async () => {
const f = await fixture();
await f.completion.failRunSetup(f.setup(new ConfigurationIncompleteFailure("missing credential", { configurationIncomplete: { reason: "missing_binding" } })));
diff --git a/server/src/services/heartbeat/run-completion.ts b/server/src/services/heartbeat/run-completion.ts
index 7f2b486dfe7..95026919171 100644
--- a/server/src/services/heartbeat/run-completion.ts
+++ b/server/src/services/heartbeat/run-completion.ts
@@ -1,4 +1,5 @@
import { measureRunPhase } from "../execution-preparation-timing.js";
+import { PROJECT_ACCESS_DENIED, ProjectAccessDeniedError } from "../project-access-denial.js";
import { pendingCaseHandoffForRun } from "./case-handoff-lifecycle.js";
import { NON_RETRYABLE_PREFLIGHT_FAILURE_CODES, isHeartbeatRunTerminalStatus } from "./run-lifecycle.js";
import { ChatControlRecoveryUnresolvedError } from "./queue.js";
@@ -145,6 +146,7 @@ export interface FailHeartbeatRunSetupInput {
}
function nonRetryablePreflightFailureCode(error: unknown): string | null {
+ if (error instanceof ProjectAccessDeniedError) return PROJECT_ACCESS_DENIED;
if (error instanceof ChatControlRecoveryUnresolvedError)
return CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE;
if (
@@ -1344,6 +1346,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
// action, so it is persisted even when the agent lookup failed and the
// agent-scoped stop metadata cannot be merged in.
const setupFailureDetails =
+ (outerErr instanceof ProjectAccessDeniedError
+ ? { projectAccessDenied: { projectId: outerErr.projectId } } : null) ??
(workspaceGitScanFailure ? {
workspaceGitScan: {
code: workspaceGitScanFailure.code,
@@ -1453,7 +1457,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
).catch(() => undefined);
if (
!isWorkspaceValidationFailedRun(livenessRun) &&
- !isConfigurationIncompleteFailedRun(livenessRun)
+ !isConfigurationIncompleteFailedRun(livenessRun) &&
+ livenessRun.errorCode !== PROJECT_ACCESS_DENIED
) {
await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(
() => undefined,
@@ -1462,15 +1467,17 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
// No provider work began. Retry temporary host scan failures with
// the existing durable failure budget, before releasing execution.
// Generic recovery must not grant a second budget on exhaustion.
- await (isTransientWorkspaceGitScanCode(livenessRun.errorCode)
- ? scheduleBoundedRetryForRun(livenessRun, failedAgent)
- : scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent)
- ).catch((retryError) => {
- logger.warn(
- { err: retryError, runId: livenessRun.id },
- "failed to schedule interaction continuation retry after setup failure",
- );
- });
+ if (livenessRun.errorCode !== PROJECT_ACCESS_DENIED) {
+ await (isTransientWorkspaceGitScanCode(livenessRun.errorCode)
+ ? scheduleBoundedRetryForRun(livenessRun, failedAgent)
+ : scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent)
+ ).catch((retryError) => {
+ logger.warn(
+ { err: retryError, runId: livenessRun.id },
+ "failed to schedule interaction continuation retry after setup failure",
+ );
+ });
+ }
}
await releaseIssueExecutionAndPromote(livenessRun, {
suppressImmediateRecovery:
diff --git a/server/src/services/heartbeat/run-lifecycle.ts b/server/src/services/heartbeat/run-lifecycle.ts
index 5f299e855fa..3bbb6f30b40 100644
--- a/server/src/services/heartbeat/run-lifecycle.ts
+++ b/server/src/services/heartbeat/run-lifecycle.ts
@@ -244,6 +244,7 @@ const LIVENESS_BOOKKEEPING_EVENT_TYPES = [
];
export const NON_RETRYABLE_PREFLIGHT_FAILURE_CODES = new Set([
+ "project_access_denied",
"low_trust_isolation_unavailable",
"low_trust_requires_isolated_workspace",
"low_trust_boundary_mismatch",
diff --git a/server/src/services/project-access-denial.ts b/server/src/services/project-access-denial.ts
new file mode 100644
index 00000000000..f9e699d1bdc
--- /dev/null
+++ b/server/src/services/project-access-denial.ts
@@ -0,0 +1,10 @@
+import { HttpError } from "../errors.js";
+
+export const PROJECT_ACCESS_DENIED = "project_access_denied";
+
+/** Carries a safe, typed setup failure without changing the authorization decision. */
+export class ProjectAccessDeniedError extends HttpError {
+ constructor(readonly projectId: string) {
+ super(403, "Project is outside this low-trust boundary.");
+ }
+}
diff --git a/server/src/services/recovery/provider-failure-classification.test.ts b/server/src/services/recovery/provider-failure-classification.test.ts
index 31407ac82c3..29dded8d4d6 100644
--- a/server/src/services/recovery/provider-failure-classification.test.ts
+++ b/server/src/services/recovery/provider-failure-classification.test.ts
@@ -10,6 +10,10 @@ import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery
import { isConfigurationIncompleteFailedRun } from "../../modules/wake-queue/domain/values.js";
describe("classifyAdapterFailureForRecovery", () => {
+ it("does not automatically replay a project access denial", () => {
+ expect(classifyContinuationFailure({ errorCode: "project_access_denied", resultJson: null } as never))
+ .toMatchObject({ kind: "non_retryable", maxAttempts: 0 });
+ });
it("waits for model configuration changes instead of retrying a typed native model rejection", () => {
const run = { errorCode: "native_provider_model_rejected", error: "Fixed model configuration message", resultJson: null };
expect(classifyAdapterFailureForRecovery(run)).toEqual({ kind: "configuration_incomplete" });
diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts
index e72811ec8d0..331122e10d6 100644
--- a/server/src/services/recovery/service.ts
+++ b/server/src/services/recovery/service.ts
@@ -516,6 +516,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set([
]);
const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set([
+ "project_access_denied",
"native_provider_model_rejected",
"provider_tool_definition_invalid",
"adapter_engine_unavailable",
diff --git a/server/src/services/task-interruption-presentation.test.ts b/server/src/services/task-interruption-presentation.test.ts
new file mode 100644
index 00000000000..9c730d1c5fc
--- /dev/null
+++ b/server/src/services/task-interruption-presentation.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { projectTaskInterruption } from "./task-interruption-presentation.js";
+
+const issue = { id: "task", companyId: "11111111-1111-4111-8111-111111111111", status: "blocked", assigneeAgentId: "agent", projectId: "22222222-2222-4222-8222-222222222222" };
+const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: null,
+ errorCode: "project_access_denied", error: null, livenessState: null,
+ resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } };
+const agent = { status: "idle", pauseReason: null };
+
+describe("task project-access interruption", () => {
+ it("explains a project denial without offering a futile retry", () => {
+ expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: false })).toMatchObject({
+ state: "waiting_for_user", responsibility: "user", userAction: "project_access", canRetry: false,
+ projectAccess: { agentId: "agent", projectId: "22222222-2222-4222-8222-222222222222", ready: false },
+ });
+ });
+
+ it("projects the historical setup denial without requiring another failed run", () => {
+ expect(projectTaskInterruption(issue, { run: { ...run, errorCode: "setup_failed",
+ error: "Project is outside this low-trust boundary.", resultJson: null }, agent, paused: false, approval: false }))
+ .toMatchObject({ userAction: "project_access", canRetry: false });
+ });
+
+ it("offers an explicit retry after the project is added, without changing the failed run", () => {
+ expect(projectTaskInterruption(issue, { run, agent, projectAccessAllowed: true, paused: false, approval: false }))
+ .toMatchObject({ state: "failed", canRetry: true, projectAccess: { ready: true } });
+ expect(run.status).toBe("failed");
+ });
+
+ it("shows an accepted retry as working after access is repaired", () => {
+ const input = { run, agent, paused: false, approval: false, wait: { id: "request", requestedAt: new Date() } };
+ expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true }))
+ .toMatchObject({ state: "working", canRetry: false });
+ expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })).not.toHaveProperty("projectAccess");
+ expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: false }))
+ .toMatchObject({ state: "waiting_for_user", userAction: "project_access", canRetry: false });
+ });
+
+ it("does not reinterpret unrelated failures or an old assignee's denial", () => {
+ for (const otherRun of [{ ...run, errorCode: "setup_failed", error: "Disk full", resultJson: null },
+ { ...run, agentId: "previous-agent" }]) {
+ expect(projectTaskInterruption(issue, { run: otherRun, agent, paused: false, approval: false }))
+ .not.toHaveProperty("projectAccess");
+ }
+ });
+
+ it("keeps approval, pause, running and completed states authoritative", () => {
+ expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: true })).toMatchObject({ userAction: "approval" });
+ expect(projectTaskInterruption(issue, { run, agent, paused: true, approval: false })).toMatchObject({ userAction: "resume" });
+ expect(projectTaskInterruption(issue, { run: { ...run, status: "running" }, agent, paused: false, approval: false }))
+ .toMatchObject({ state: "working" });
+ expect(projectTaskInterruption({ ...issue, status: "done" }, { run, agent, paused: false, approval: false }))
+ .toMatchObject({ state: "resolved" });
+ });
+});
diff --git a/server/src/services/task-interruption-presentation.ts b/server/src/services/task-interruption-presentation.ts
index 249ff55d8e5..f8dd6664e33 100644
--- a/server/src/services/task-interruption-presentation.ts
+++ b/server/src/services/task-interruption-presentation.ts
@@ -4,21 +4,35 @@ import { agents, agentWakeupRequests, approvals, companies, heartbeatRuns, issue
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
import { readSystemRecovery } from "./system-recovery-state.js";
+import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js";
+import { authorizationService } from "./authorization.js";
type ProjectionIssue = Pick;
type ProjectionRun = Pick;
+ "id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> &
+ Partial>;
type ProjectionState = {
run?: ProjectionRun;
action?: typeof issueRecoveryActions.$inferSelect;
wait?: { id: string; requestedAt: Date };
agent?: Pick;
+ projectAccessAllowed?: boolean;
project?: Pick;
company?: Pick;
paused: boolean;
approval: boolean;
};
+/** Recognize old persisted failures without displaying arbitrary run error text. */
+function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined {
+ const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary.";
+ if (!run || run.status !== "failed" || run.agentId !== issue.assigneeAgentId
+ || (run.errorCode !== PROJECT_ACCESS_DENIED && !historical)) return undefined;
+ const detail = run.resultJson?.projectAccessDenied;
+ return detail && typeof detail === "object" && "projectId" in detail && typeof detail.projectId === "string"
+ ? detail.projectId : historical ? issue.projectId : null;
+}
+
/** Presentation never grants authority. Dispatch repeats all gates under its
* locks. The same projection is used in detail, lists and attention surfaces. */
export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null {
@@ -36,6 +50,14 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
? "budget" : input.paused || project?.pausedAt || agent?.status === "paused" || company?.status === "paused" ? "resume" : null;
if (userAction) return { ...base, responsibility: "user", state: "waiting_for_user", userAction };
if (["running", "queued"].includes(run.status)) return { ...base, state: "working" };
+ const deniedProject = deniedProjectId(issue, run);
+ let projectAccess: TaskInterruptionPresentation["projectAccess"];
+ if (deniedProject !== undefined) {
+ const ready = input.projectAccessAllowed === true;
+ projectAccess = { agentId: run.agentId!, projectId: deniedProject === issue.projectId ? deniedProject : null, ready };
+ if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user",
+ userAction: "project_access", projectAccess };
+ }
const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime();
if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled",
nextRetryAt: new Date(retryAt).toISOString(), canRetry: true };
@@ -44,7 +66,7 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
activeObservation > now - 45_000) return { ...base, state: "working" };
if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted")
return { ...base, state: "working" };
- return { ...base, state: "failed", canRetry: true };
+ return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) };
}
/** Batch list reads rather than doing seven queries per row. Keep run logs and
@@ -61,8 +83,12 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []);
const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows] = await Promise.all([
db.selectDistinctOn([runIssue], { issueId: runIssue, id: heartbeatRuns.id, status: heartbeatRuns.status,
+ agentId: heartbeatRuns.agentId,
+ error: sql`case when ${heartbeatRuns.errorCode} = 'setup_failed'
+ and ${heartbeatRuns.error} = 'Project is outside this low-trust boundary.' then ${heartbeatRuns.error} else null end`,
errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
- resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete')`,
+ resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete',
+ 'projectAccessDenied', ${heartbeatRuns.resultJson}->'projectAccessDenied')`,
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), inArray(runIssue, ids)))
.orderBy(runIssue, desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)),
db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt })
@@ -91,12 +117,32 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
)).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : [];
const byIssue = (rows: T[]) => new Map(rows.map(row => [row.issueId, row]));
const runMap = byIssue(runs), waitMap = byIssue(waits);
+ // Recheck current authority, including standard trust, root-issue grants and
+ // project policy. Do not reuse the failed run's frozen policy for a new retry.
+ // Only denied tasks need these reads; deduplicate projects and batch per agent.
+ const deniedByAgent = new Map>();
+ for (const issue of selected) {
+ const projectId = deniedProjectId(issue, runMap.get(issue.id));
+ if (!projectId || !issue.assigneeAgentId || ["done", "cancelled"].includes(issue.status)) continue;
+ const ids = deniedByAgent.get(issue.assigneeAgentId) ?? new Set();
+ ids.add(projectId);
+ deniedByAgent.set(issue.assigneeAgentId, ids);
+ }
+ const currentAccess = new Map();
+ const authorization = authorizationService(db);
+ for (const [agentId, ids] of deniedByAgent) {
+ const projectIds = [...ids];
+ const decisions = await authorization.decideReads({ type: "agent", agentId, companyId, source: "agent_key" },
+ projectIds.map(projectId => ({ type: "project" as const, companyId, projectId })));
+ projectIds.forEach((projectId, index) => currentAccess.set(`${agentId}:${projectId}`, decisions[index]?.allowed === true));
+ }
const actionMap = new Map(actions.map(action => [action.sourceIssueId, action]));
const agentMap = new Map(agentRows.map(agent => [agent.id, agent]));
const projectMap = new Map(projectRows.map(project => [project.id, project]));
const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId));
for (const issue of selected) result.set(issue.id, projectTaskInterruption(issue, {
run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id),
+ projectAccessAllowed: currentAccess.get(`${issue.assigneeAgentId}:${deniedProjectId(issue, runMap.get(issue.id))}`),
agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""),
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
}));
diff --git a/server/src/services/task-workspace-access.ts b/server/src/services/task-workspace-access.ts
index b49ba9d9432..31d40f79655 100644
--- a/server/src/services/task-workspace-access.ts
+++ b/server/src/services/task-workspace-access.ts
@@ -8,6 +8,7 @@ import { canonicalExecutionUuid } from "./execution-identity.js";
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isDeepStrictEqual } from "node:util";
+import { ProjectAccessDeniedError } from "./project-access-denial.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
type DbTransaction = Parameters[0]>[0];
@@ -19,7 +20,12 @@ export async function assertTaskWorkspaceSourceProjectAccess(
const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({
actor, action: "project:read", resource: { type: "project", companyId, projectId },
}));
- if (!decision.allowed) throw forbidden(decision.explanation);
+ if (!decision.allowed) {
+ if (actor.type === "agent" && decision.reason === "deny_low_trust_boundary") {
+ throw new ProjectAccessDeniedError(projectId);
+ }
+ throw forbidden(decision.explanation);
+ }
}
export async function assertExecutionWorkspaceRecordAccess(
diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts
index 5d31460ea38..9f97ce68bfe 100644
--- a/ui/src/api/agents.ts
+++ b/ui/src/api/agents.ts
@@ -1,4 +1,5 @@
import type { AgentPublicIdentity } from "@paperclipai/shared";
+import type { AgentProjectAccess } from "@paperclipai/shared";
import type {
Agent,
EnvironmentProbeResult,
@@ -111,6 +112,8 @@ function agentPath(id: string, companyId?: string, suffix = "") {
}
export const agentsApi = {
+ projectAccess: (id: string) => api.get(`/agents/${id}/project-access`),
+ addProjectAccess: (id: string, projectIds: string[]) => api.post(`/agents/${id}/project-access`, { projectIds }),
qualifyRunner: (id: string, expectedEnvironmentId: string, companyId?: string) =>
api.post(agentPath(id, companyId, "/qualify-runner"), { expectedEnvironmentId }),
setAvatar: (companyId: string, agentId: string, imageBase64: string | null) =>
diff --git a/ui/src/components/AgentProjectAccessSettings.tsx b/ui/src/components/AgentProjectAccessSettings.tsx
new file mode 100644
index 00000000000..2320755c85f
--- /dev/null
+++ b/ui/src/components/AgentProjectAccessSettings.tsx
@@ -0,0 +1,34 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { isUuidLike } from "@paperclipai/shared";
+import { agentsApi } from "../api/agents";
+import { projectsApi } from "../api/projects";
+import { queryKeys } from "../lib/queryKeys";
+import { Link, useSearchParams } from "../lib/router";
+import { AllowedProjectsEditor } from "./AllowedProjectsEditor";
+import { Button } from "./ui/button";
+
+export function AgentProjectAccessSettings({ agent }: { agent: { id: string; companyId: string; name: string; urlKey?: string } }) {
+ const client = useQueryClient();
+ const [params] = useSearchParams();
+ const fromTask = params.get("fromTask");
+ const access = useQuery({ queryKey: queryKeys.agents.projectAccess(agent.id), queryFn: () => agentsApi.projectAccess(agent.id) });
+ const projects = useQuery({ queryKey: queryKeys.projects.list(agent.companyId), queryFn: () => projectsApi.list(agent.companyId) });
+ if (access.error || projects.error) return
+
Couldn’t load project access.
{ void access.refetch(); void projects.refetch(); }}>Try again
+
;
+ if (!access.data || !projects.data) return Loading project access…
;
+ return
+
{
+ const saved = await agentsApi.addProjectAccess(agent.id, projectIds);
+ client.setQueryData(queryKeys.agents.projectAccess(agent.id), saved);
+ await Promise.all([
+ client.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
+ ...(agent.urlKey ? [client.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) })] : []),
+ client.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) }),
+ client.invalidateQueries({ queryKey: ["issues"] }),
+ ]);
+ }} />
+ {fromTask && isUuidLike(fromTask) && Return to task }
+ ;
+}
diff --git a/ui/src/components/AllowedProjectsEditor.tsx b/ui/src/components/AllowedProjectsEditor.tsx
new file mode 100644
index 00000000000..a34ced1479d
--- /dev/null
+++ b/ui/src/components/AllowedProjectsEditor.tsx
@@ -0,0 +1,72 @@
+import { useState } from "react";
+import { Check, FolderOpen, Loader2, Plus, X } from "lucide-react";
+import { Button } from "./ui/button";
+import { Checkbox } from "./ui/checkbox";
+import { Input } from "./ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+
+export interface AllowedProjectsEditorProps {
+ projects: Array<{ id: string; name: string }>;
+ projectIds: string[];
+ canManage: boolean;
+ agentName: string;
+ onSave: (projectIds: string[]) => Promise;
+}
+
+export function AllowedProjectsEditor({ projects, projectIds, canManage, agentName, onSave }: AllowedProjectsEditorProps) {
+ const [added, setAdded] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+ const pending = added.filter(id => !projectIds.includes(id));
+ const visibleIds = [...new Set([...projectIds, ...pending])];
+ const name = (id: string) => projects.find(project => project.id === id)?.name ?? "Unavailable project";
+ const matches = projects.filter(project => project.name.toLowerCase().includes(query.toLowerCase()));
+
+ async function save() {
+ if (!canManage || saving || !pending.length) return;
+ setSaving(true); setError(null); setSaved(false);
+ try { await onSave(pending); setAdded([]); setSaved(true); }
+ catch (failure) { setError(failure instanceof Error ? failure.message : "Couldn’t save project access."); }
+ finally { setSaving(false); }
+ }
+
+ return
+
+
Allowed projects
+
Projects whose tasks and repositories this agent may access.
+ {canManage &&
+ Add project
+
+ setQuery(event.target.value)} />
+ {matches.map(project =>
+ { setAdded(current => checked ? [...new Set([...current, project.id])] : current.filter(id => id !== project.id)); setSaved(false); }} />
+ {project.name}{projectIds.includes(project.id) && Already allowed }
+ )}
+ {!matches.length && No projects found.
}
+ setOpen(false)}>Done
+ }
+
+ {visibleIds.length ?
+ {visibleIds.map(id =>
+ {name(id)}
+ {projectIds.includes(id) ? Allowed
+ : <>To add setAdded(current => current.filter(value => value !== id))}> >}
+ )}
+ : No projects allowed yet.
}
+ {!canManage && A person with agent-management permission can add projects here.
}
+ {pending.length > 0 &&
+
Add access to {pending.map(name).join(", ")}
+
{agentName} will be able to access work and repositories in {pending.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.
+
}
+ {error && Couldn’t save project access. Your selection is kept. {error}
}
+ {saved && Project access saved. Return to your task to retry.
}
+ {canManage &&
+ { setAdded([]); setError(null); setSaved(false); }}>Cancel
+ void save()}>{saving && }{saving ? "Saving access…" : error ? "Try saving again" : "Save project access"}
+
}
+ ;
+}
diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx
index 9c34d72748f..17bd169750c 100644
--- a/ui/src/components/ExecutionBlockerNotice.tsx
+++ b/ui/src/components/ExecutionBlockerNotice.tsx
@@ -2,9 +2,10 @@ import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useSecondTick } from "../hooks/useSecondTick";
+import { TaskProjectAccessNotice } from "./TaskProjectAccessNotice";
/** One ordinary interruption surface. Technical records remain in diagnostics. */
-export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className }: {
+export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className, issueId, agentName, projectName }: {
presentation: TaskInterruptionPresentation | null | undefined;
canRetry: boolean;
onRetry: () => void;
@@ -12,8 +13,14 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
takingLonger?: boolean;
error?: Error | null;
className?: string;
+ issueId?: string;
+ agentName?: string;
+ projectName?: string;
}) {
useSecondTick(presentation?.state === "retry_scheduled");
+ if (presentation?.projectAccess && !optimistic && !takingLonger) return ;
if (error && !optimistic && !takingLonger) return {error.message} {canRetry && Retry }
;
if (takingLonger) return Taking longer than usual… {canRetry && Retry }
;
if (optimistic || !presentation || presentation.responsibility !== "system" ||
diff --git a/ui/src/components/ProjectAccessNotice.tsx b/ui/src/components/ProjectAccessNotice.tsx
new file mode 100644
index 00000000000..07386a68de5
--- /dev/null
+++ b/ui/src/components/ProjectAccessNotice.tsx
@@ -0,0 +1,38 @@
+import { ShieldCheck, ShieldX, ArrowRight } from "lucide-react";
+import { Button } from "./ui/button";
+import { cn } from "../lib/utils";
+
+export interface ProjectAccessNoticeProps {
+ agentName?: string;
+ projectName?: string;
+ ready: boolean;
+ canManage: boolean;
+ canRetry: boolean;
+ onManage: () => void;
+ onRetry: () => void;
+ error?: Error | null;
+ className?: string;
+}
+
+export function ProjectAccessNotice({ agentName = "This agent", projectName, ready, canManage, canRetry, onManage, onRetry, error, className }: ProjectAccessNoticeProps) {
+ return
+
+ {ready ?
:
}
+
+
{ready ? "Project access is ready" : `${agentName} can’t access this project`}
+
{ready
+ ? "The agent can now access the required project. Retry to continue with your saved message."
+ : `This task needs ${projectName ?? "a project"} outside the agent’s allowed projects. The agent hasn’t started. Your message is saved.`}
+ {!ready && !canManage &&
A person with agent-management permission can add this project in the agent’s Permissions / Trust settings.
}
+
+
+
+ {ready ? canRetry &&
Retry task
+ :
{canManage ? "Manage project access" : "View agent access"} }
+
+ {error && {error.message}
}
+ {!ready && Run details
+ Stopped during Workspace setup Reason Project outside allowed scope Provider started No
+ }
+ ;
+}
diff --git a/ui/src/components/TaskProjectAccessNotice.tsx b/ui/src/components/TaskProjectAccessNotice.tsx
new file mode 100644
index 00000000000..021d955f8ec
--- /dev/null
+++ b/ui/src/components/TaskProjectAccessNotice.tsx
@@ -0,0 +1,23 @@
+import { useQuery } from "@tanstack/react-query";
+import type { TaskInterruptionPresentation } from "@paperclipai/shared";
+import { agentsApi } from "../api/agents";
+import { useNavigate } from "../lib/router";
+import { queryKeys } from "../lib/queryKeys";
+import { ProjectAccessNotice } from "./ProjectAccessNotice";
+
+export function TaskProjectAccessNotice({ access, issueId, agentName, projectName, ...props }: {
+ access: NonNullable;
+ issueId?: string;
+ agentName?: string;
+ projectName?: string;
+ canRetry: boolean;
+ onRetry: () => void;
+ error?: Error | null;
+ className?: string;
+}) {
+ const navigate = useNavigate();
+ const permission = useQuery({ queryKey: queryKeys.agents.projectAccess(access.agentId), queryFn: () => agentsApi.projectAccess(access.agentId) });
+ return navigate(`/agents/${access.agentId}/permissions${issueId ? `?fromTask=${encodeURIComponent(issueId)}` : ""}`)} />;
+}
diff --git a/ui/src/components/TrustPresetSection.tsx b/ui/src/components/TrustPresetSection.tsx
index 132ab11bff3..cae599bbdf6 100644
--- a/ui/src/components/TrustPresetSection.tsx
+++ b/ui/src/components/TrustPresetSection.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentPermissions, TrustPreset } from "@paperclipai/shared";
import { Lock, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -59,6 +59,7 @@ export function TrustPresetSection({
issueCandidates = [],
candidatesLoading,
allowSingleIssue = true,
+ projectAccessEditor,
}: {
permissions: Partial | null | undefined;
onChange: (permissions: Partial) => void;
@@ -68,6 +69,7 @@ export function TrustPresetSection({
issueCandidates?: LowTrustBoundaryCandidate[];
candidatesLoading?: boolean;
allowSingleIssue?: boolean;
+ projectAccessEditor?: ReactNode;
}) {
const [policyOpen, setPolicyOpen] = useState(false);
const preset = getTrustPreset(permissions);
@@ -148,7 +150,7 @@ export function TrustPresetSection({
: "This agent is set to low-trust review, but no project, root issue, or issue scope is set in the core policy. Add a scope before this agent can run without denial."}
- {boundaryEditable ? (
+ {projectAccessEditor && (boundary?.projectIds?.length ?? 0) > 0 ? null : boundaryEditable ? (
@@ -211,7 +213,7 @@ export function TrustPresetSection({
)}
-
+ {!projectAccessEditor &&
Want to set more than one containment boundary?{" "}
Get Paperclip EE.
-
+ }
) : null}
+ {lowTrust && projectAccessEditor}
+
{managedPermissions.authorizationPolicy?.reviewPreset ? null : (
Advanced permissions remain editable through the EE permissions extension when installed.
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index 85bdb7694fd..26dafb622df 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -222,6 +222,7 @@ export const queryKeys = {
["team-catalog", "installed", companyId] as const,
},
agents: {
+ projectAccess: (id: string) => ["agents", "project-access", id] as const,
identity: (id: string) => ["agents", "identity", id] as const,
list: (companyId: string) => ["agents", companyId] as const,
detail: (id: string) => ["agents", "detail", id] as const,
diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx
index 1258faa6955..b89c8f42501 100644
--- a/ui/src/pages/AgentDetail.production.tsx
+++ b/ui/src/pages/AgentDetail.production.tsx
@@ -4,7 +4,7 @@ import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
-import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
+import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
agentsApi,
@@ -58,6 +58,7 @@ import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { TrustPresetSection } from "../components/TrustPresetSection";
+import { AgentProjectAccessSettings } from "../components/AgentProjectAccessSettings";
import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
@@ -768,6 +769,7 @@ export function AgentDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
+ const location = useLocation();
const [actionError, setActionError] = useState(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState>(() => new Set());
const { enabled: chatEnabled, githubEnabled, loaded: chatConnectorsLoaded } = useChatConnectorsEnabled();
@@ -987,10 +989,10 @@ export function AgentDetail() {
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
- navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
+ navigate(`/agents/${canonicalAgentRef}/${canonicalTab}${location.search}`, { replace: true });
return;
}
- }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate, chatConnectorsLoaded]);
+ }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate, chatConnectorsLoaded, location.search]);
useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -2209,6 +2211,7 @@ function ConfigurationTab({
) : null}
{content === "configuration" ? }
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index ccc3d42335f..5a2693c0e2d 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -11,7 +11,7 @@ import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
-import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
+import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
agentsApi,
@@ -63,6 +63,7 @@ import { InlineBanner } from "../components/InlineBanner";
import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
import { TrustPresetSection } from "../components/TrustPresetSection";
+import { AgentProjectAccessSettings } from "../components/AgentProjectAccessSettings";
import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
@@ -780,6 +781,7 @@ export function AgentDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
+ const location = useLocation();
const [actionError, setActionError] = useState(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState>(() => new Set());
const activeView: AgentDetailView = urlRunId ? "run-detail"
@@ -983,10 +985,10 @@ export function AgentDetail() {
if (legacyAuditSection) return;
const canonicalTab = activeView === "run-detail" ? "overview" : activeView;
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
- navigate(agentDetailHref(canonicalAgentRef, canonicalTab), { replace: true });
+ navigate(`${agentDetailHref(canonicalAgentRef, canonicalTab)}${location.search}`, { replace: true });
return;
}
- }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate]);
+ }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, location.search]);
useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -2135,6 +2137,7 @@ export function ConfigurationTab({
{content === "permissions" ? }
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index 5913637d3f0..c64b6fc4e3f 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -394,6 +394,7 @@ export function FailedRunInboxRow({
: interruption?.state === "waiting_for_user" ? ({
approval: "Approval required", reconnect: "Reconnect account", assign_agent: "Assign an agent", choose_model: "Choose a supported model",
resume: "Work is paused", budget: "Budget limit reached",
+ project_access: "Agent needs project access",
}[interruption.userAction ?? "resume"]) : runFailureMessage(run);
const retryAvailable = !interruption || interruption.canRetry;
const showUnreadSlot = unreadState !== null;
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index fcb321a66f1..abac74816d5 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -7814,6 +7814,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
)}
{resolvedDetailTab === "chat" && (
)}
diff --git a/ui/storybook/stories/design-proposals/project-access.stories.tsx b/ui/storybook/stories/design-proposals/project-access.stories.tsx
new file mode 100644
index 00000000000..8efb0bc3a7e
--- /dev/null
+++ b/ui/storybook/stories/design-proposals/project-access.stories.tsx
@@ -0,0 +1,121 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import { ProjectAccessProposal } from "../../support/design-proposals/ProjectAccessProposal";
+
+const meta = {
+ id: "design-project-access",
+ title: "Design proposals/Patterns/Project access recovery",
+ component: ProjectAccessProposal,
+ parameters: {
+ layout: "fullscreen",
+ initialEntries: ["/PAP/issues/DEMO-42"],
+ docs: { description: { component: "Start at Blocked task. Manage project access opens the proposed agent settings. Add Agent Extensions, review the additive access change, save, return to the task and retry. Low-trust review stays active. Settings supports multiple allowed projects without replacing the existing one. Read-only, pending save, failed save and failed retry states remain actionable. The reply and every state transition are local fixtures; no project permissions change and no provider runs. Reset the story to restore the starting state." } },
+ paperclip: {
+ capabilities: ["recovery/diagnosis", "recovery/source-action", "agents/instructions"],
+ home: "design-proposals", status: "proposed",
+ exception: "Requested design for a visible project denial and additive project access repair before production implementation.",
+ implementation: "prototype", boundary: "fixtures",
+ notes: "Reuses DesignPageShell, DesignSidebar, AgentAvatar, IssueStatusBadge, TaskChatBubble, TaskChatLiveRunPill and shared form primitives. Notice, access editor and navigation are a local prototype. Synthetic names and repositories only. Save retains existing projects; failed saves preserve draft but grant nothing. Retry is unavailable before a saved grant and does not run on save. Read-only viewers cannot edit. No production API, persistence, permission, provider or recovery proof. The implemented notice and editor are covered by the task-project-access stories; this retains the original design snapshot. Server regression tests and application verification cover authorization, persistence and explicit retry separately.",
+ },
+ },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const BlockedTask: Story = {};
+export const AgentSettings: Story = { args: { initialView: "permissions" } };
+export const AccessSaved: Story = { args: { initialState: "access_saved" } };
+export const ReadOnly: Story = { args: { canManage: false }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(await canvas.findByText("Reviewer can’t access this project")).toBeVisible();
+ await expect(canvas.queryByRole("button", { name: "Manage project access" })).not.toBeInTheDocument();
+ await userEvent.click(canvas.getByRole("button", { name: "View agent access" }));
+ await expect(canvas.getByText("Only people with agent configuration permission can change this access.")).toBeVisible();
+ await expect(canvas.queryByRole("button", { name: "Add project" })).not.toBeInTheDocument();
+} };
+export const Light: Story = { globals: { theme: "light" } };
+export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } };
+export const MobileSettings: Story = { args: { initialView: "permissions" }, globals: { viewport: { value: "mobile1", isRotated: false } } };
+
+async function addProject(canvasElement: HTMLElement, name = "Agent Extensions") {
+ const canvas = within(canvasElement), screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(await canvas.findByRole("button", { name: "Add project" }));
+ await userEvent.click(await screen.findByRole("checkbox", { name }));
+ await userEvent.click(screen.getByRole("button", { name: "Done" }));
+}
+
+export const RepairJourney: Story = { play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step("Explain the denied project before any provider work", async () => {
+ await expect(await canvas.findByText("Reviewer can’t access this project")).toBeVisible();
+ await expect(canvas.getByRole("alert")).toHaveTextContent("The agent hasn’t started. Your message is saved.");
+ await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
+ await userEvent.click(canvas.getByRole("button", { name: "Manage project access" }));
+ });
+ await step("Add a project while preserving existing containment", async () => {
+ await expect(canvas.getByRole("button", { name: "Save project access" })).toBeDisabled();
+ await addProject(canvasElement);
+ await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
+ await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("To add");
+ await expect(canvas.getByText("Low-trust review", { exact: true })).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByText("Project access saved.")).toBeVisible();
+ await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
+ await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("Allowed");
+ });
+ await step("Explicitly retry the saved message after saving access", async () => {
+ await userEvent.click(canvas.getByRole("button", { name: "Return to task" }));
+ await expect(canvas.getByText("Project access is ready")).toBeVisible();
+ await waitFor(() => expect(canvas.getByText("Are you there?")).toBeVisible());
+ await userEvent.click(canvas.getByRole("button", { name: "Retry task" }));
+ await waitFor(() => expect(canvas.getByText("Yes, I’m here. What would you like me to look at?")).toBeVisible());
+ await expect(canvas.queryByRole("alert")).not.toBeInTheDocument();
+ });
+} };
+
+export const CancelJourney: Story = { args: { initialView: "permissions" }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
+ await expect(canvas.getByText("Reviewer can’t access this project")).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Manage project access" }));
+ await expect(canvas.queryByTestId("project-extensions")).not.toBeInTheDocument();
+ await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
+} };
+
+export const SaveFailure: Story = { args: { initialView: "permissions", failSave: true }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByRole("alert")).toHaveTextContent("No permissions changed. Your selection is kept.");
+ await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("To add");
+ await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
+} };
+export const SaveFailureJourney: Story = { args: { initialView: "permissions", failSave: true }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await userEvent.click(await canvas.findByRole("button", { name: "Try saving again" }));
+ await expect(await canvas.findByText("Project access saved.")).toBeVisible();
+ await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("Allowed");
+} };
+export const Saving: Story = { args: { initialView: "permissions", holdSaving: true }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(canvas.getByRole("button", { name: "Saving access…" })).toBeDisabled();
+ await expect(canvas.getByRole("button", { name: "Cancel" })).toBeDisabled();
+} };
+export const RetryFailure: Story = { args: { initialState: "access_saved", failRetry: true }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(await canvas.findByRole("button", { name: "Retry task" }));
+ await expect(await canvas.findByRole("alert")).toHaveTextContent("Couldn’t start the retry. Project access is saved.");
+} };
+export const MultipleProjects: Story = { args: { initialView: "permissions" }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await addProject(canvasElement, "Website");
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByText("Project access saved.")).toBeVisible();
+ for (const id of ["product", "extensions", "website"]) await expect(canvas.getByTestId(`project-${id}`)).toHaveTextContent("Allowed");
+} };
diff --git a/ui/storybook/stories/recovery/project-access.stories.tsx b/ui/storybook/stories/recovery/project-access.stories.tsx
new file mode 100644
index 00000000000..3349e6605b3
--- /dev/null
+++ b/ui/storybook/stories/recovery/project-access.stories.tsx
@@ -0,0 +1,77 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+import { expect, fn, userEvent, within } from "storybook/test";
+import { AllowedProjectsEditor } from "@/components/AllowedProjectsEditor";
+import { ProjectAccessNotice } from "@/components/ProjectAccessNotice";
+
+const meta = {
+ id: "task-project-access",
+ title: "Projects and execution/Recovery/Task chat/Project access",
+ component: ProjectAccessNotice,
+ parameters: { paperclip: {
+ capabilities: ["recovery/diagnosis", "agents/instructions"], implementation: "production", boundary: "fixtures",
+ notes: "Production notice and additive project editor. Local save callbacks cover selection, read-only, cancellation and failed-save states. API authorization, persistence and explicit retry admission are separate server regressions and local application verification.",
+ } },
+ decorators: [(Story) =>
],
+ args: { agentName: "Reviewer", projectName: "Agent Extensions", ready: false, canManage: true, canRetry: false, onManage: fn(), onRetry: fn() },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const BlockedTask: Story = { play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByRole("alert")).toHaveTextContent("Your message is saved.");
+ await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
+ await expect(canvas.getByRole("button", { name: "Manage project access" })).toBeVisible();
+} };
+export const AccessSaved: Story = { args: { ready: true, canRetry: true } };
+export const ReadOnly: Story = { args: { canManage: false } };
+export const Light: Story = { globals: { theme: "light" } };
+export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } };
+
+const projects = [{ id: "product", name: "Product" }, { id: "extensions", name: "Agent Extensions" }, { id: "website", name: "Website" }];
+function Editor({ fail = false, canManage = true }: { fail?: boolean; canManage?: boolean }) {
+ const [ids, setIds] = useState(["product"]);
+ const [failed, setFailed] = useState(false);
+ return {
+ if (fail && !failed) { setFailed(true); throw new Error("Connection interrupted."); }
+ setIds(current => [...new Set([...current, ...added])]);
+ }} />;
+}
+async function selectProject(canvasElement: HTMLElement) {
+ const canvas = within(canvasElement), screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(canvas.getByRole("button", { name: "Add project" }));
+ await expect(await screen.findByRole("checkbox", { name: "Product" })).toBeDisabled();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Agent Extensions" }));
+ await userEvent.click(screen.getByRole("button", { name: "Done" }));
+}
+export const AgentSettings: Story = { render: () => };
+export const SaveAccess: Story = { ...AgentSettings, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await selectProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByRole("status")).toHaveTextContent("Project access saved");
+ await expect(canvas.getAllByText("Allowed", { exact: true })).toHaveLength(2);
+ await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
+} };
+export const CancelAddition: Story = { ...AgentSettings, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await selectProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
+ await expect(canvas.queryByText("Agent Extensions", { exact: true })).not.toBeInTheDocument();
+ await expect(canvas.getByText("Product")).toBeVisible();
+} };
+export const SaveFailure: Story = { render: () => , play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await selectProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByRole("alert")).toHaveTextContent("Your selection is kept");
+ await expect(canvas.getByText("To add")).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Try saving again" }));
+ await expect(await canvas.findByRole("status")).toHaveTextContent("Project access saved");
+} };
+export const ReadOnlySettings: Story = { render: () => , play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).queryByRole("button", { name: "Add project" })).not.toBeInTheDocument();
+} };
+export const MobileSettings: Story = { ...AgentSettings, globals: { viewport: { value: "mobile1", isRotated: false } } };
diff --git a/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx b/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx
new file mode 100644
index 00000000000..a8894e19af5
--- /dev/null
+++ b/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx
@@ -0,0 +1,180 @@
+import { useState } from "react";
+import { ArrowLeft, ArrowRight, Check, FolderOpen, Loader2, Plus, ShieldCheck, ShieldX, X } from "lucide-react";
+import { AgentAvatar } from "@/components/AgentAvatar";
+import { DesignPageShell } from "@/components/DesignPageShell";
+import { DesignSidebar } from "@/components/DesignSidebar";
+import { IssueStatusBadge } from "@/components/StatusBadge";
+import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
+import { TaskChatLiveRunPill } from "@/components/task-chat/TaskChatLiveRunPill";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { DesignProposalAccess } from "./DesignProposalAccess";
+
+const projects = [
+ { id: "product", name: "Product", repository: "acme/product" },
+ { id: "extensions", name: "Agent Extensions", repository: "acme/agent-extensions" },
+ { id: "website", name: "Website", repository: "acme/website" },
+];
+const agent = { id: "reviewer-preview", name: "Reviewer" };
+const wait = () => new Promise(resolve => setTimeout(resolve, 650));
+
+export interface ProjectAccessProposalProps {
+ initialView?: "task" | "permissions";
+ initialState?: "blocked" | "access_saved";
+ canManage?: boolean;
+ failSave?: boolean;
+ failRetry?: boolean;
+ holdSaving?: boolean;
+}
+
+/** Design-only state machine. It never calls a permissions or execution API. */
+function Proposal({ initialView = "task", initialState = "blocked", canManage = true,
+ failSave = false, failRetry = false, holdSaving = false }: ProjectAccessProposalProps) {
+ const [view, setView] = useState(initialView);
+ const [allowed, setAllowed] = useState(initialState === "access_saved" ? ["product", "extensions"] : ["product"]);
+ const [draft, setDraft] = useState(allowed);
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(false);
+ const [saveAttempted, setSaveAttempted] = useState(false);
+ const [savedNotice, setSavedNotice] = useState(false);
+ const [run, setRun] = useState<"stopped" | "starting" | "replied">("stopped");
+ const [retryError, setRetryError] = useState(false);
+ const [retryAttempted, setRetryAttempted] = useState(false);
+ const [shellNotice, setShellNotice] = useState("");
+ const added = projects.filter(project => draft.includes(project.id) && !allowed.includes(project.id));
+ const accessReady = allowed.includes("extensions");
+
+ function openPermissions() {
+ setDraft(allowed);
+ setSavedNotice(false);
+ setSaveError(false);
+ setView("permissions");
+ }
+
+ async function save() {
+ if (!canManage || saving || !added.length) return;
+ setSaving(true);
+ setSaveError(false);
+ if (holdSaving) return;
+ await wait();
+ setSaving(false);
+ setSaveAttempted(true);
+ if (failSave && !saveAttempted) { setSaveError(true); return; }
+ setAllowed([...draft]);
+ setSavedNotice(true);
+ }
+
+ async function retry() {
+ if (!accessReady || run === "starting") return;
+ setRun("starting");
+ setRetryError(false);
+ await wait();
+ setRetryAttempted(true);
+ if (failRetry && !retryAttempted) { setRun("stopped"); setRetryError(true); return; }
+ setRun("replied");
+ }
+
+ const task =
+
+
+ DEMO-42 · Agent Extensions
+
+
Quick check-in
+
+
+ Reviewer
+
+
+ {run === "starting" ?
+ : run === "replied" ?
+ :
+
+ {accessReady ?
:
}
+
+
{accessReady ? "Project access is ready" : "Reviewer can’t access this project"}
+
{accessReady
+ ? "Agent Extensions is now in Reviewer’s allowed projects. Retry to continue with your saved message."
+ : "This task uses Agent Extensions, which isn’t in Reviewer’s allowed projects. The agent hasn’t started. Your message is saved."}
+ {!accessReady && !canManage &&
An organization admin can add this project in Reviewer’s Permissions / Trust settings.
}
+
+
+
+ {accessReady ?
void retry()}>Retry task
+ : canManage ?
Manage project access
+ :
View agent access }
+
+ {retryError && Couldn’t start the retry. Project access is saved. Try again.
}
+ {!accessReady && Run details
+ Stopped during Workspace setup Reason Project outside allowed scope Provider started No
+ }
+ }
+ {run === "replied" &&
View saved project access }
+
;
+
+ const permissions =
+
setView("task")} disabled={saving}> Back to task
+
Reviewer Permissions / Trust
+
+
+ Low-trust review Runs in an isolated sandbox. Untrusted output stays quarantined.
+
+
+
+
Allowed projects
+
Projects whose tasks and repositories this agent may access.
+
+ {canManage &&
+ Add project
+
+ setQuery(event.target.value)} />
+ {projects.filter(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())).map(project =>
+ { setDraft(current => checked ? [...new Set([...current, project.id])] : current.filter(id => id !== project.id)); setSavedNotice(false); }} />
+ {project.name} {allowed.includes(project.id) ? "Already allowed" : project.repository}
+ )}
+ {!projects.some(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())) && No projects found.
}
+ setPickerOpen(false)}>Done
+ }
+
+
+ {projects.filter(project => draft.includes(project.id)).map(project =>
+ {project.name}
{project.repository}
+ {allowed.includes(project.id) ? Allowed
+ : <>To add setDraft(current => current.filter(id => id !== project.id))}> >}
+ )}
+
+ {!accessReady && DEMO-42 is waiting for access to Agent Extensions .
}
+ {!canManage && Only people with agent configuration permission can change this access.
}
+ {added.length > 0 &&
+
Add access to {added.map(project => project.name).join(" and ")}
+
Reviewer will be able to access work and repositories in {added.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.
+
}
+ {saveError && Couldn’t save project access. No permissions changed. Your selection is kept.
}
+ {savedNotice &&
+
Project access saved.
+
setView("task")}>Return to task
+
}
+ {canManage &&
+ { setDraft(allowed); setSaveError(false); setView("task"); }}>Cancel
+ void save()}>{saving && }{saving ? "Saving access…" : saveError ? "Try saving again" : "Save project access"}
+
}
+
+
;
+
+ return
+
setShellNotice("This preview focuses on project access. Use Manage project access to try the repair.")} />}
+ headerActions={Design preview }>
+ {shellNotice && {shellNotice}
}
+ {view === "task" ? task : permissions}
+
+
;
+}
+
+export function ProjectAccessProposal(props: ProjectAccessProposalProps) {
+ return ;
+}