mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
feat(auth): add independent agent configuration read permission (#18766)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents need to inspect configuration and instructions when they set up teammates. > - Configuration reads currently depend on configure or suggestion grants. > - Legacy standard agents did not receive those newer grants. Some self-read routes also lose the target agent identity. > - This pull request adds an independent read grant and applies only that grant to eligible existing agents. > - The benefit is that agents can inspect and verify configuration without new write authority. ## Linked Issues or Issue Description Refs #15283. Related earlier proposals: #8064 and #12050. Those proposals target older authorization code; this change uses the current configure/suggest model and adds a read-only rollout for existing agents. Related bounded peer metadata proposal: #14160. **What existing behavior does this improve?** Agent configuration, configured skill, instruction, and revision reads through the existing APIs and native instruction tools. **Current behavior** An agent needs `agents:configure` or `agents:suggest-changes` to inspect a peer. Older standard agents can hire a teammate but cannot verify the saved instructions. Configuration and revision routes also reject self-reads because they authorize a company resource instead of the target agent. **Proposed behavior** Add `agents:read` for redacted configuration and instruction reads. Keep `agents:configure` as read/write and preserve suggestion-grant compatibility. Standard new agents receive the read grant. A one-time migration grants only reads to eligible existing standard agents. Individual routes authorize the target agent and grant scope. **Reason and benefit** Read access can be granted without configuration changes, rollback, or environment probes. Legacy agents can verify teammates without receiving new write permissions. ## What Changed - Add `agents:read` to the shared permission contract and standard-agent defaults. - Accept read, configure, or suggestion grants for configuration reads, with target scope and the responsible-user ceiling. - Fix target-aware self and scoped configuration, skill, and revision reads. - Add an audited data migration. Skip low-trust, built-in, inactive, pending, terminated, setup, and scoped configuration/suggestion cases. Preserve existing read scopes and later revocation. - Update the specifications, default-permission documentation, and agent access map. ## Verification - Focused acceptance: 276 tests passed across seven suites on commit `a37314df88`: authorization, real-key HTTP access, route authorization, canonical instructions, default grants, migration replay, and lifecycle configuration. - The HTTP journey uses a real hashed agent API key and a disposable PostgreSQL database. It verifies redaction, scoped reads, denied edits and rollback, cross-company isolation, and both agent and responsible-user revocation. - New read-grant regressions failed before the authorization change. A realistic paused/pausing/resuming migration fixture also failed before the lifecycle filter correction and passed afterward. Setup-held paused agents stay excluded. - `pnpm -r typecheck` and `pnpm build` passed on the rebased branch; the final lifecycle migration correction also passed the database package typecheck and build. The latest commit changes only a test assertion. - `pnpm --filter @paperclipai/db check:migrations`, `node scripts/check-maps.mjs`, and `git diff --check` passed. - Full current-head [CI run](https://github.com/paperclipai/paperclip/actions/runs/41641531426) passed: typecheck, build, all general and serialized test lanes, Runner checks, all eight browser shards, canary dry run, policy, and aggregate gates. The local full-suite attempt was stopped after exposing the outdated cross-company 403 assertion; interrupted local full-suite attempts are not counted as passes. ## Risks The migration deliberately expands redacted configuration and instruction visibility for eligible existing standard agents in the same company. An absent older write grant does not deny the new read capability. It adds no write grant. Scoped configure/suggestion grants and all existing read-grant scopes stay intact. Administrators can remove the independent read grant after the one-time rollout; ordinary updates and startup do not restore it. Configure and suggestion grants still imply reads within their own scopes. Cross-company configuration/history reads now return 404 through the common target-aware helper. No schema change or credential-value disclosure is added. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, and code execution. The host does not expose a more specific runtime model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
44a9a043b8
commit
86b365f992
17 files changed
+385
-45
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agents, agentApiKeys, authUsers, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db";
|
||||
import { actorMiddleware } from "../middleware/auth.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
|
||||
(support.supported ? describe : describe.skip)("agent configuration read access", () => {
|
||||
it("authenticates a scoped read-only key, redacts reads, denies writes, and rechecks revocation", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("agent-config-read-access-");
|
||||
const db = createDb(database.connectionString);
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "agent-config-read-home-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
try {
|
||||
const companyId = randomUUID(), foreignCompanyId = randomUUID(), actorId = randomUUID(), targetId = randomUUID(), otherId = randomUUID(), foreignId = randomUUID(), userId = randomUUID();
|
||||
const plaintext = "config-read-secret-must-not-leak";
|
||||
const token = randomUUID();
|
||||
const root = resolveManagedInstructionsRoot({ id: targetId, companyId, name: "Target", adapterConfig: {} });
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.writeFile(path.join(root, "AGENTS.md"), "Peer operating instructions");
|
||||
await db.insert(companies).values([
|
||||
{ id: companyId, name: "Config read", issuePrefix: "CRA" },
|
||||
{ id: foreignCompanyId, name: "Foreign", issuePrefix: "CRB" },
|
||||
]);
|
||||
await db.insert(authUsers).values({ id: userId, name: "Read owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(agents).values([
|
||||
{ id: actorId, companyId, name: "Reader" },
|
||||
{ id: targetId, companyId, name: "Target", adapterConfig: {
|
||||
env: { GENERIC_NAME: { type: "plain", value: plaintext } },
|
||||
instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: "AGENTS.md",
|
||||
} },
|
||||
{ id: otherId, companyId, name: "Outside grant" },
|
||||
{ id: foreignId, companyId: foreignCompanyId, name: "Foreign target" },
|
||||
]);
|
||||
await db.insert(companyMemberships).values([
|
||||
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
|
||||
{ companyId, principalType: "agent", principalId: actorId, membershipRole: "member" },
|
||||
]);
|
||||
await db.insert(principalPermissionGrants).values([
|
||||
{ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:read", scope: { agentIds: [targetId] } },
|
||||
{ companyId, principalType: "user", principalId: userId, permissionKey: "agents:read" },
|
||||
]);
|
||||
await db.insert(agentApiKeys).values({ companyId, agentId: actorId, name: "Reader", responsibleUserId: userId,
|
||||
keyHash: createHash("sha256").update(token).digest("hex") });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(actorMiddleware(db, { deploymentMode: "authenticated" }));
|
||||
app.use("/api", agentRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const get = (url: string) => request(app).get(`/api${url}`).set("Authorization", `Bearer ${token}`);
|
||||
const configuration = `/agents/${targetId}/configuration`;
|
||||
|
||||
const read = await get(configuration);
|
||||
expect(read.status).toBe(200);
|
||||
expect(JSON.stringify(read.body)).not.toContain(plaintext);
|
||||
expect(read.body.adapterConfig.env.GENERIC_NAME.value).toBe("***REDACTED***");
|
||||
expect((await get(`/agents/${targetId}/skills`)).status).toBe(200);
|
||||
expect((await get(`/agents/${targetId}/config-revisions`)).status).toBe(200);
|
||||
const instructions = await get(`/agents/${targetId}/instructions-bundle/file?path=AGENTS.md`);
|
||||
expect(instructions.status).toBe(200);
|
||||
expect(instructions.body.content).toBe("Peer operating instructions");
|
||||
expect((await get(`/agents/${otherId}/configuration`)).status).toBe(403);
|
||||
expect((await get(`/agents/${foreignId}/configuration`)).status).toBe(404);
|
||||
expect((await get(`/companies/${companyId}/agent-configurations`)).status).toBe(403);
|
||||
|
||||
const write = await request(app).patch(`/api/agents/${targetId}`).set("Authorization", `Bearer ${token}`).send({ title: "Denied edit" });
|
||||
expect(write.status).toBe(403);
|
||||
const rollback = await request(app).post(`/api/agents/${targetId}/config-revisions/${randomUUID()}/rollback`).set("Authorization", `Bearer ${token}`).send({});
|
||||
expect(rollback.status).toBe(403);
|
||||
expect((await db.select({ title: agents.title }).from(agents).where(eq(agents.id, targetId)))[0]!.title).toBeNull();
|
||||
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
|
||||
expect((await get(configuration)).status).toBe(403);
|
||||
expect((await get(`/agents/${actorId}/configuration`)).status).toBe(200);
|
||||
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:read" });
|
||||
await db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalId, userId)));
|
||||
const humanRevoked = await get(configuration);
|
||||
expect(humanRevoked.status).toBe(403);
|
||||
expect(humanRevoked.body.code).toBe("RESPONSIBLE_USER_UNAUTHORIZED");
|
||||
expect(await fs.readFile(path.join(root, "AGENTS.md"), "utf8")).toBe("Peer operating instructions");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
await database.cleanup();
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { agentJoinGrantsFromDefaults } from "../services/invite-grants.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describeDatabase = support.supported ? describe : describe.skip;
|
||||
const expectedNewAgentGrantKeys: PermissionKey[] = [
|
||||
"agents:read",
|
||||
"agents:configure",
|
||||
"agents:suggest-changes",
|
||||
"skills:create",
|
||||
|
||||
@@ -64,6 +64,20 @@ describe("canonical instruction revisions", () => {
|
||||
async function save(content: string, baseRevisionId: string | null) {
|
||||
return service.commit({ ...target(), entryFile, content, baseRevisionId, source: "cleanup" }, actor);
|
||||
}
|
||||
it("reads peer instructions with read-only agent and responsible-user grants but denies saves", async () => {
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
|
||||
await db.insert(principalPermissionGrants).values([
|
||||
{ companyId, principalType: "user", principalId: userId, permissionKey: "agents:read", scope: { agentIds: [agentId] } },
|
||||
{ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:read", scope: { agentIds: [agentId] } },
|
||||
]);
|
||||
const first = await base();
|
||||
expect(first.content).toBe(initial);
|
||||
await expect(service.history({ ...target(), entryFile }, actor)).resolves.toMatchObject({ revisions: [] });
|
||||
await expect(save("read access cannot write", first.revision.id)).rejects.toMatchObject({ status: 403 });
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
|
||||
await expect(base()).rejects.toMatchObject({ status: 403 });
|
||||
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
||||
});
|
||||
it.each(["current", "history", "revision", "diff", "candidates"])("denies peer %s reads without a configuration grant or responsible-user target access", async (operation) => {
|
||||
const first = await base();
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
|
||||
|
||||
@@ -2166,6 +2166,31 @@ describe("agent permission routes", () => {
|
||||
});
|
||||
|
||||
describe("agent configuration read gate", () => {
|
||||
it.each(["configuration", "config-revisions", "config-revisions/55555555-5555-4555-8555-555555555555"])("allows self %s reads without peer configuration grants", async (suffix) => {
|
||||
const plaintext = "self-config-value-must-not-leak";
|
||||
const adapterConfig = { env: { GENERIC_NAME: { type: "plain", value: plaintext } } };
|
||||
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterConfig });
|
||||
const revision = { id: "55555555-5555-4555-8555-555555555555", beforeConfig: { adapterConfig }, afterConfig: { adapterConfig } };
|
||||
mockAgentService.listConfigRevisions.mockResolvedValue([revision]);
|
||||
mockAgentService.getConfigRevision.mockResolvedValue(revision);
|
||||
mockAccessService.decide.mockImplementation(async (input) => ({
|
||||
allowed: input.action === "agent_config:read" && input.resource.type === "agent" && input.resource.agentId === agentId,
|
||||
reason: "allow_self",
|
||||
explanation: "Self configuration read",
|
||||
}));
|
||||
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key" });
|
||||
|
||||
const response = await request(app).get(`/api/agents/${agentId}/${suffix}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.stringify(response.body)).not.toContain(plaintext);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId, agentId },
|
||||
scope: { targetAgentId: agentId },
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows a board member without agents:create to read agent configuration", async () => {
|
||||
// Board (human) users with company membership but no agents:create
|
||||
// grant should still be able to view agent configuration — this is
|
||||
@@ -2297,11 +2322,12 @@ describe("agent permission routes", () => {
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
resource: { type: "agent", companyId, agentId: peerAgentId },
|
||||
scope: { targetAgentId: peerAgentId },
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows an agent actor with agents:suggest-changes grant to read peer config", async () => {
|
||||
it.each(["agents:read", "agents:configure", "agents:suggest-changes"])("allows an agent actor with %s grant to read peer config", async (permissionKey) => {
|
||||
// Suggest-tier authority implies read access so the agent can prepare a
|
||||
// consented diff without receiving direct change authority.
|
||||
const peerAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
@@ -2316,11 +2342,11 @@ describe("agent permission routes", () => {
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by explicit grant agents:suggest-changes.",
|
||||
explanation: `Allowed by explicit grant ${permissionKey}.`,
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
permissionKey: "agents:suggest-changes",
|
||||
permissionKey,
|
||||
scope: null,
|
||||
},
|
||||
});
|
||||
@@ -2338,7 +2364,8 @@ describe("agent permission routes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
resource: { type: "agent", companyId, agentId: peerAgentId },
|
||||
scope: { targetAgentId: peerAgentId },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,6 +269,47 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign");
|
||||
});
|
||||
|
||||
it.each(["agent", "user"] as const)("allows %s read-only grants without configuration write authority", async (principalType) => {
|
||||
const company = await createCompany(db, "ReadOnlyConfig");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
const userId = await createUser(db);
|
||||
const actor: AuthorizationActor = principalType === "agent"
|
||||
? { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }
|
||||
: { type: "board", userId, source: "session" };
|
||||
if (principalType === "agent") await grantAgentPermission(db, company.id, actorAgent.id, "agents:read");
|
||||
else await grantUserPermission(db, company.id, userId, "agents:read");
|
||||
const authz = authorizationService(db);
|
||||
const resource = { type: "agent" as const, companyId: company.id, agentId: targetAgent.id };
|
||||
|
||||
await expect(authz.decide({ actor, action: "agent_config:read", resource })).resolves.toMatchObject({
|
||||
allowed: true, grant: { permissionKey: "agents:read" },
|
||||
});
|
||||
for (const action of ["agent_config:update", "agent_instructions:update"] as const) {
|
||||
await expect(authz.decide({ actor, action, resource, scope: { requiresChangeGrant: true } }))
|
||||
.resolves.toMatchObject({ allowed: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("restricts configuration reads to the read grant's target scope", async () => {
|
||||
const company = await createCompany(db, "ScopedReadOnlyConfig");
|
||||
const actor = await createAgent(db, company.id);
|
||||
const target = await createAgent(db, company.id);
|
||||
const other = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, actor.id, "agents:read", { agentIds: [target.id] });
|
||||
const authz = authorizationService(db);
|
||||
const read = (targetAgentId: string) => authz.decide({
|
||||
actor: { type: "agent", companyId: company.id, agentId: actor.id, source: "agent_key" },
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgentId },
|
||||
scope: { targetAgentId },
|
||||
});
|
||||
await expect(read(target.id)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(read(other.id)).resolves.toMatchObject({ allowed: false, reason: "deny_scope" });
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actor.id));
|
||||
await expect(read(target.id)).resolves.toMatchObject({ allowed: false, reason: "deny_missing_grant" });
|
||||
});
|
||||
|
||||
it("allows suggest grants to read peer agent configuration", async () => {
|
||||
const company = await createCompany(db, "AgentReadGrant");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
@@ -292,7 +333,7 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the direct config-read grant decision when a suggest read grant is scoped away", async () => {
|
||||
it("reports a scoped legacy read grant denial when no read grant applies", async () => {
|
||||
const company = await createCompany(db, "AgentReadScopedSuggestGrant");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
@@ -308,8 +349,8 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_missing_grant",
|
||||
explanation: "Missing permission: agents:configure.",
|
||||
reason: "deny_scope",
|
||||
explanation: "Permission agents:suggest-changes does not cover the requested scope.",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared
|
||||
|
||||
/** Direct grants for newly activated standard agents. Existing agents are not backfilled. */
|
||||
export const NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS = [
|
||||
"agents:read",
|
||||
"agents:configure",
|
||||
"agents:suggest-changes",
|
||||
"skills:create",
|
||||
|
||||
@@ -130,7 +130,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, agent.id), eq(activityLog.action, "agent.updated")))).toHaveLength(1);
|
||||
await request(board).patch(`/api/agents/${agent.id}`).send({ name: "Stale", expectedConfigurationRevision: response.body.configurationRevision }).expect(409);
|
||||
const foreign = app({ type: "board", source: "session", userId: "outsider", companyIds: [], memberships: [] });
|
||||
await request(foreign).get(`/api/agents/${agent.id}/configuration`).expect(403);
|
||||
await request(foreign).get(`/api/agents/${agent.id}/configuration`).expect(404);
|
||||
await request(foreign).patch(`/api/agents/${agent.id}`).send({ title: "Denied", expectedConfigurationRevision: saved.body.configurationRevision }).expect(404);
|
||||
});
|
||||
|
||||
|
||||
@@ -1656,9 +1656,6 @@ export function agentRoutes(
|
||||
// are never exposed. Mutations and environment probes still gate on
|
||||
// agents:create or agents:configure via the mutating route helpers.
|
||||
//
|
||||
// For AGENT actors we keep a stricter gate: an agent must have either
|
||||
// agents:configure or agents:suggest-changes before it can inspect peer
|
||||
// agent configuration for a proposed diff.
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "agent") {
|
||||
const decision = await access.decide({
|
||||
@@ -1683,7 +1680,7 @@ export function agentRoutes(
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
||||
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string, targetAgentId?: string) {
|
||||
// Mirrors assertCanReadConfigurations but returns a boolean instead of
|
||||
// throwing. Board actors only need company access; agent actors must pass
|
||||
// the agent configuration read grant ladder so peer agents cannot snoop
|
||||
@@ -1697,7 +1694,8 @@ export function agentRoutes(
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
resource: targetAgentId ? { type: "agent", companyId, agentId: targetAgentId } : { type: "company", companyId },
|
||||
...(targetAgentId ? { scope: { targetAgentId } } : {}),
|
||||
});
|
||||
return decision.allowed;
|
||||
}
|
||||
@@ -1839,6 +1837,7 @@ export function agentRoutes(
|
||||
actor: req.actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
scope: { targetAgentId: targetAgent.id },
|
||||
});
|
||||
if (decision.allowed) return;
|
||||
|
||||
@@ -3799,7 +3798,7 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanReadConfigurations(req, agent.companyId);
|
||||
await assertCanReadAgent(req, agent);
|
||||
|
||||
const adapter = findActiveServerAdapter(agent.adapterType);
|
||||
if (!adapter?.listSkills) {
|
||||
@@ -4188,7 +4187,7 @@ export function agentRoutes(
|
||||
}
|
||||
const canReadSensitiveDetail = isSelf
|
||||
? true
|
||||
: await actorCanReadConfigurationsForCompany(req, agent.companyId);
|
||||
: await actorCanReadConfigurationsForCompany(req, agent.companyId, agent.id);
|
||||
if (!canReadSensitiveDetail) {
|
||||
res.json(await buildAgentDetail(agent, { restricted: true }));
|
||||
return;
|
||||
@@ -4203,7 +4202,7 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanReadConfigurations(req, agent.companyId);
|
||||
await assertCanReadAgent(req, agent);
|
||||
res.json(redactAgentConfiguration(agent));
|
||||
});
|
||||
|
||||
@@ -4214,7 +4213,7 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanReadConfigurations(req, agent.companyId);
|
||||
await assertCanReadAgent(req, agent);
|
||||
const revisions = await svc.listConfigRevisions(id);
|
||||
res.json(revisions.map((revision) => redactConfigRevision(revision)));
|
||||
});
|
||||
@@ -4227,7 +4226,7 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanReadConfigurations(req, agent.companyId);
|
||||
await assertCanReadAgent(req, agent);
|
||||
const revision = await svc.getConfigRevision(id, revisionId);
|
||||
if (!revision) {
|
||||
res.status(404).json({ error: "Revision not found" });
|
||||
|
||||
@@ -1887,30 +1887,20 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
): Promise<AuthorizationDecision> {
|
||||
const configureDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: "agents:configure",
|
||||
scope: input.scope,
|
||||
});
|
||||
if (configureDecision.allowed || configureDecision.reason === "deny_missing_membership") {
|
||||
return configureDecision;
|
||||
let denied: AuthorizationDecision | null = null;
|
||||
for (const permissionKey of ["agents:read", "agents:configure", "agents:suggest-changes"] as const) {
|
||||
const decision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey,
|
||||
scope: input.scope,
|
||||
});
|
||||
if (decision.allowed || decision.reason === "deny_missing_membership") return decision;
|
||||
if (!denied || decision.reason === "deny_scope") denied = decision;
|
||||
}
|
||||
|
||||
const suggestDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: input.scope,
|
||||
});
|
||||
if (suggestDecision.allowed || suggestDecision.reason === "deny_missing_grant") {
|
||||
return suggestDecision;
|
||||
}
|
||||
return configureDecision;
|
||||
return denied!;
|
||||
}
|
||||
|
||||
async function decideWithProtectedChangeGrants(
|
||||
|
||||
Reference in new issue
Block a user