Route agent permissions through authorization service

Land the PAP-9413 follow-on by routing agents.ts permission checks through the central authorization decision service and hardening Codex managed-home auth symlink creation against the concurrent EEXIST path.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-22 01:51:03 +00:00
1 parent c1e801c82b
commit 15cf25b794
5 files changed
+133 -7

No files matched your search

+1
View File
@@ -118,6 +118,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
+2
View File
@@ -141,6 +141,8 @@ Hierarchical reporting structure. CEO at top, reports cascade down.
**Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control.
Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place.
Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what.
### Cross-Team Work
@@ -0,0 +1,57 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareManagedCodexHome } from "./codex-home.js";
describe("codex managed home", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("treats a concurrently-created expected auth symlink as success", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const managedCodexHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"codex-home",
);
const sharedAuth = path.join(sharedCodexHome, "auth.json");
const managedAuth = path.join(managedCodexHome, "auth.json");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8");
const originalSymlink = fs.symlink.bind(fs);
vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => {
await originalSymlink(source, target, type);
const error = new Error("file already exists") as NodeJS.ErrnoException;
error.code = "EEXIST";
throw error;
});
try {
await expect(
prepareManagedCodexHome(
{
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_INSTANCE_ID: "default",
},
async () => {},
"company-1",
),
).resolves.toBe(managedCodexHome);
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
@@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
}
async function isExpectedSymlink(target: string, source: string): Promise<boolean> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing?.isSymbolicLink()) return false;
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return false;
return path.resolve(path.dirname(target), linkedPath) === path.resolve(source);
}
async function createExpectedSymlink(target: string, source: string): Promise<void> {
try {
await fs.symlink(source, target);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EEXIST" && await isExpectedSymlink(target, source)) return;
throw error;
}
}
async function ensureSymlink(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
return;
}
@@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise<void> {
return;
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
if (await isExpectedSymlink(target, source)) return;
await fs.unlink(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
}
async function ensureCopiedFile(target: string, source: string): Promise<void> {
+50
View File
@@ -543,6 +543,24 @@ export function agentRoutes(
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
const decide = (access as typeof access & { decide?: typeof access.decide }).decide;
if (typeof decide === "function") {
const decision = await decide({
actor: req.actor,
action: "agents:create",
resource: { type: "company", companyId },
});
if (!decision.allowed) {
throw forbidden(decision.explanation);
}
if (req.actor.type !== "agent") return null;
const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
return actorAgent;
}
if (req.actor.type === "board") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null;
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
@@ -566,6 +584,17 @@ export function agentRoutes(
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
const decide = (access as typeof access & { decide?: typeof access.decide }).decide;
if (typeof decide === "function") {
const decision = await decide({
actor: req.actor,
action: "agents:create",
resource: { type: "company", companyId },
});
if (decision.allowed) return;
throw forbidden(decision.explanation);
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
if (!allowed) {
@@ -592,6 +621,16 @@ export function agentRoutes(
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
const decide = (access as typeof access & { decide?: typeof access.decide }).decide;
if (typeof decide === "function") {
const decision = await decide({
actor: req.actor,
action: "agent_config:read",
resource: { type: "company", companyId },
});
return decision.allowed;
}
if (req.actor.type === "board") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
return access.canUser(companyId, req.actor.userId, "agents:create");
@@ -672,6 +711,17 @@ export function agentRoutes(
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
const decide = (access as typeof access & { decide?: typeof access.decide }).decide;
if (typeof decide === "function") {
const decision = await decide({
actor: req.actor,
action: "agent_config:update",
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
});
if (decision.allowed) return;
throw forbidden(decision.explanation);
}
if (req.actor.type === "board") {
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
return;