fix: read-only agent config/skill endpoints should not require agents:create (#3725)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Access to agents, skills, and configurations is governed by a per-company permission system > - `agents:create` is a mutation-tier permission that controls who can create or modify agents > - `assertCanReadConfigurations` delegates to `assertCanCreateAgentsForCompany`, effectively requiring `agents:create` just to *read* agent configs, skills, and config revisions > - That's a permission regression: any company member without `agents:create` hits 403 on the Skills tab, agent config pages, and revision history — but those responses are already secret-redacted > - This pull request loosens the read gate to company membership only, while keeping every mutation-adjacent gate at `agents:create` ## Linked Issues or Issue Description No existing issue covers this directly — problem described in-PR following the bug-report template: **What happened** `assertCanReadConfigurations` delegates to `assertCanCreateAgentsForCompany`, effectively requiring the mutation-tier `agents:create` permission just to *read* agent configs, skills, and config revisions. Any company member without `agents:create` hits 403 on the Skills tab, agent config pages, and revision history — even though those responses are already secret-redacted (`redactAgentConfiguration`, `redactConfigRevision`). **Expected behavior** Read-only configuration/skill/revision endpoints are readable by any company member; only mutation-adjacent endpoints require `agents:create`. **Steps to reproduce** As a company member without `agents:create`, open the Skills tab or an agent config page (or `GET` the config/skill/revision endpoints) — the request fails with 403. ## What Changed - `server/src/routes/agents.ts`: - `assertCanReadConfigurations` now requires company membership only (plus the existing agent-key cross-company check). Previously it required `agents:create`. - `actorCanReadConfigurationsForCompany` (the boolean twin, used by `GET /agents/:id` to decide whether to return a restricted detail) now uses the standard try/catch-around-`assertCompanyAccess` pattern. - `POST /companies/:companyId/adapters/:type/test-environment` is not a pure read (it exercises adapter secrets) and now calls `assertCanCreateAgentsForCompany` directly instead of going through `assertCanReadConfigurations`. Behavior for this endpoint is unchanged. ## Verification - Existing tests pass. - Manual: log in as a company member without an `agents:create` grant. Visit the Skills tab on an agent and the agent configuration panel — both load. Try to edit the agent — blocked, as before. - Manual: POST to `/companies/:companyId/adapters/:type/test-environment` as the same user — still 403. ## Risks Low. The only behavior change is on read endpoints whose responses were already redacted (\`redactAgentConfiguration\`, \`redactConfigRevision\`). No secret escapes anywhere. ## Model Used Claude Opus 4.6 (1M context), extended thinking mode. ## Checklist - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Tests run locally and pass - [x] CI green - [x] Greptile review addressed Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
c21f70ef1c
commit
3701be76fa
@@ -1492,6 +1492,88 @@ describe.sequential("agent permission routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent configuration read gate", () => {
|
||||
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
|
||||
// the read-only permission loosening introduced by this PR.
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/agents/${agentId}/configuration`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies an agent actor without agents:create when reading peer config", async () => {
|
||||
// Agent actors must still pass the agents:create gate (explicit
|
||||
// grant OR canCreateAgents permission on the agent record). A peer
|
||||
// agent in the same company without that permission must not be
|
||||
// able to read another agent's configuration.
|
||||
const peerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const peerAgent = { ...baseAgent, id: peerAgentId };
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
if (id === peerAgentId) return peerAgent;
|
||||
if (id === agentId) {
|
||||
return { ...baseAgent, permissions: { canCreateAgents: false } };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
runId: "run-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows an agent actor with agents:create grant to read peer config", async () => {
|
||||
// When an agent actor has an explicit agents:create grant in the
|
||||
// access service, the read gate must let them through.
|
||||
const peerAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const peerAgent = { ...baseAgent, id: peerAgentId };
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
if (id === peerAgentId) return peerAgent;
|
||||
if (id === agentId) {
|
||||
return { ...baseAgent, permissions: { canCreateAgents: false } };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(
|
||||
async (_companyId: string, _principalType: string, principalId: string, key: string) => {
|
||||
return principalId === agentId && key === "agents:create";
|
||||
},
|
||||
);
|
||||
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
runId: "run-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects heartbeat cancellation outside the caller company scope", async () => {
|
||||
mockHeartbeatService.getRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
|
||||
Reference in New Issue
Block a user