fix(agents): confirm the hiring parent's company before credential inheritance

The hire route copied provider credential references from the hiring
agent to the hired agent, but the merge checked only the adapter
type, not the hiring agent's company. Add a check that the hiring
agent belongs to the target company, so a copy can never cross a
company boundary. Add regression tests for the codex_local,
grok_local, and claude_local credential paths.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
nickyleach 2026-09-11 22:27:46 +00:00
parent c2e517d948
commit 65e3780f3e
2 changed files with 67 additions and 1 deletions

View File

@ -333,6 +333,66 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
expect(Object.keys(childEnvOf(res))).toHaveLength(0);
});
it("does not inherit a codex_local credential from a hiring agent in another company", async () => {
const parentCompanyId = await seedCompany();
const targetCompanyId = await seedCompany();
const secret = await createCompanySecret(parentCompanyId, "sk-openai-other-company");
const parent = await seedParentAgent(parentCompanyId, "codex_local", {
OPENAI_API_KEY: secretRef(secret.id),
});
// The actor claims the target company, but the named hiring agent belongs
// to a different company. The route must reject the request and must
// never copy the other company's credential reference.
const res = await hire(agentActor(targetCompanyId, parent.id), targetCompanyId, {
name: "Codex Cross-Company Child",
role: "engineer",
adapterType: "codex_local",
});
expect(res.status).toBe(403);
const children = await db.select().from(agents).where(eq(agents.companyId, targetCompanyId));
expect(children).toHaveLength(0);
});
it("does not inherit a grok_local credential from a hiring agent in another company", async () => {
const parentCompanyId = await seedCompany();
const targetCompanyId = await seedCompany();
const secret = await createCompanySecret(parentCompanyId, "xai-other-company");
const parent = await seedParentAgent(parentCompanyId, "grok_local", {
XAI_API_KEY: secretRef(secret.id),
});
const res = await hire(agentActor(targetCompanyId, parent.id), targetCompanyId, {
name: "Grok Cross-Company Child",
role: "engineer",
adapterType: "grok_local",
});
expect(res.status).toBe(403);
const children = await db.select().from(agents).where(eq(agents.companyId, targetCompanyId));
expect(children).toHaveLength(0);
});
it("does not inherit a claude_local credential, including the fixed OAuth binding, from a hiring agent in another company", async () => {
const parentCompanyId = await seedCompany();
const targetCompanyId = await seedCompany();
const parent = await seedParentAgent(parentCompanyId, "claude_local", {
ANTHROPIC_API_KEY: secretRef((await createCompanySecret(parentCompanyId, "ant-other-company")).id),
CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_CLAUDE_OAUTH_BINDING, version: 1 },
});
const res = await hire(agentActor(targetCompanyId, parent.id), targetCompanyId, {
name: "Claude Cross-Company Child",
role: "engineer",
adapterType: "claude_local",
});
expect(res.status).toBe(403);
const children = await db.select().from(agents).where(eq(agents.companyId, targetCompanyId));
expect(children).toHaveLength(0);
});
it("carves out a per-agent CODEX_HOME when the child inherits OPENAI_API_KEY", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "sk-openai-isolated");

View File

@ -2472,8 +2472,13 @@ export function agentRoutes(
// key inherits no Claude credential key at all. That keeps child-wins
// precedence and rules out the forbidden pairing of the fixed OAuth binding
// with an ANTHROPIC_API_KEY.
//
// The hiring agent must belong to the target company. Without that check, an
// agent that can create agents in another company could copy its own
// company's credential reference into that other company.
async function applyHiringAgentAuthInheritance(
req: Request,
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
): Promise<{ adapterConfig: Record<string, unknown>; inheritedFixedClaudeOAuthBinding: boolean }> {
@ -2483,7 +2488,7 @@ export function agentRoutes(
if (!credentialKeys) return noInheritance;
const parent = await svc.getById(req.actor.agentId);
if (!parent || parent.adapterType !== adapterType) return noInheritance;
if (!parent || parent.companyId !== companyId || parent.adapterType !== adapterType) return noInheritance;
const parentEnv = asRecord(asRecord(parent.adapterConfig)?.env);
if (!parentEnv) return noInheritance;
@ -4263,6 +4268,7 @@ export function agentRoutes(
const hiredAgentId = randomUUID();
const authInheritance = await applyHiringAgentAuthInheritance(
req,
companyId,
hireInput.adapterType,
applyCreateDefaultsByAdapterType(
hireInput.adapterType,