Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/agent-orchestrator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ things called out as open questions in the design doc.
*derived* audience includes that identity — a skill carries no roles of
its own; its audience is the intersection of its tools' `allowedRoles`,
computed at startup (ADR 0011). An unresolved identity always yields
zero candidates (ADR 0008).
zero candidates (ADR 0008). The same filter also enforces **ABAC
private-scoping** (ADR 0037): a `Tool`/`Agent`/`Skill` with a non-empty
`allowedPrincipals` is a candidate only for a caller whose resolved
principal (ADR 0030 §6) is listed — layered on top of the role check, so
an owner can mark a resource private to specific users without a
one-person role.
5. Asks an LLM (Structured Outputs, no tool-calling ability) to pick one
candidate skill for the request, then resolves that skill's declared tool
ids directly (`VectorStore.getByIds`, RBAC re-checked as a
Expand Down Expand Up @@ -132,6 +137,9 @@ spec:
input: "A URL on stdin."
output: "An envelope { status, body }."
allowedRoles: ["reader"]
# Optional ABAC private-scoping (ADR 0037): if set, only these principals
# (on top of allowedRoles) may retrieve/use the tool. Omit for a public tool.
# allowedPrincipals: ["github:owner"]
runtime: node
package: "@controller-agent/http-get"
version: "0.1.0" # exact pin required
Expand Down
6 changes: 3 additions & 3 deletions apps/agent-orchestrator/src/agent/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ describe("buildAgentGraph session-scoped active skill (ADR 0012)", () => {

expect(final.error).toBeUndefined();
expect(final.selectedSkill?.id).toBe(skill.id);
expect(deps.skillStore.getByIds).toHaveBeenCalledWith([skill.id], { callerRoles: ["reader"] });
expect(deps.skillStore.getByIds).toHaveBeenCalledWith([skill.id], { callerRoles: ["reader"], callerPrincipal: "alice" });
expect(deps.skillFitChecker.fits).toHaveBeenCalledWith("yes, publish it", skill);
// The whole point: no RAG retrieval, no selection LLM call.
expect(deps.skillStore.query).not.toHaveBeenCalled();
Expand Down Expand Up @@ -1123,7 +1123,7 @@ describe("buildAgentGraph checkIntegrationRoute (IntegrationRoute-forced dispatc
// resolved selectedSkill directly via skillStore.getByIds, routing
// straight to loadSkillTools.
expect(deps.skillStore.query).not.toHaveBeenCalled();
expect(deps.skillStore.getByIds).toHaveBeenCalledWith(["recipe-publisher-skill"], { callerRoles: ["reader"] });
expect(deps.skillStore.getByIds).toHaveBeenCalledWith(["recipe-publisher-skill"], { callerRoles: ["reader"], callerPrincipal: "alice" });
});

it("resolves a forcedAgentId directly, bypassing RAG retrieval, and delegates to that agent", async () => {
Expand Down Expand Up @@ -1615,7 +1615,7 @@ describe("buildAgentGraph Skill.agentRefs (ADR 0021, no Tool wrapper)", () => {
expect(final.selectedTool?.id).toBe("opencode-swe-agent");
expect(final.selectedTool?.agentRunTemplate).toEqual(opencodeAgent.agentRunTemplate);
expect(final.result).toBe("Opened https://github.com/imaustink/agent-controller/pull/42");
expect(deps.agentStore!.getByIds).toHaveBeenCalledWith(["opencode-swe-agent"], { callerRoles: ["reader"] });
expect(deps.agentStore!.getByIds).toHaveBeenCalledWith(["opencode-swe-agent"], { callerRoles: ["reader"], callerPrincipal: "alice" });
expect(deps.agentRunLauncher!.launch).toHaveBeenCalledWith(
opencodeAgent.agentRunTemplate,
expect.any(String),
Expand Down
44 changes: 25 additions & 19 deletions apps/agent-orchestrator/src/agent/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,7 +1167,7 @@ async function selectFallbackTool(
// No skill matched, so there is no `allowCallerTools` gate to consult — a
// consumer-supplied tool (docs/adr/0035) is simply a candidate here.
const callerTools = state.callerTools;
const candidates = await deps.vectorStore.query(state.request, { callerRoles: state.identity.roles }, deps.fallbackToolTopK ?? 3);
const candidates = await deps.vectorStore.query(state.request, callerFilter(state.identity), deps.fallbackToolTopK ?? 3);
if (candidates.length === 0 && callerTools.length === 0) return undefined;
const fitFlags = await Promise.all(candidates.map((c) => deps.toolFitChecker.fits(state.request, c.tool)));
// Caller tools skip the fit check on purpose. That gate exists because a
Expand Down Expand Up @@ -1211,7 +1211,7 @@ async function selectFallbackTool(
*/
async function hasOutOfScopeToolMatch(state: AgentState, skill: SkillDescriptor, deps: AgentGraphDeps): Promise<boolean> {
if (!state.identity) return false;
const candidates = await deps.vectorStore.query(state.request, { callerRoles: state.identity.roles }, deps.fallbackToolTopK ?? 3);
const candidates = await deps.vectorStore.query(state.request, callerFilter(state.identity), deps.fallbackToolTopK ?? 3);
const outOfScope = candidates.filter((c) => !skill.toolIds.includes(c.tool.id));
if (outOfScope.length === 0) return false;
const fitFlags = await Promise.all(outOfScope.map((c) => deps.toolFitChecker.fits(state.request, c.tool)));
Expand Down Expand Up @@ -1261,6 +1261,19 @@ async function noMatchFallback(state: AgentState, deps: AgentGraphDeps): Promise
return { result, wasFallback: true };
}

/**
* The RBAC+ABAC retrieval filter for a resolved caller, shared by every
* tool/agent/skill store query and id-lookup in the graph so both access
* dimensions are enforced identically everywhere. `callerRoles` gates RBAC
* (ADR 0004); `callerPrincipal` gates ABAC private-scoping (docs/adr/0037) and
* is the caller's resolved principal (docs/adr/0030 §6), falling back to the
* entry-point subject — the same value `resolveIdentity` already computes and
* pins onto `identity.principal`.
*/
function callerFilter(identity: Identity): { callerRoles: string[]; callerPrincipal: string } {
return { callerRoles: identity.roles, callerPrincipal: identity.principal ?? identity.subject };
}

/** Builds and compiles the LangGraph.js agent graph (docs/adr/0008, superseding the earlier flat tool-RAG flow). */
export function buildAgentGraph(deps: AgentGraphDeps) {
// The single owner of the authorization decision (docs/adr/0030 §1).
Expand Down Expand Up @@ -1302,15 +1315,11 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
// it just falls through to ordinary skill-continuity/retrieval.
if (!state.identity) return {};
if (state.forcedSkillId) {
const [skill] = await deps.skillStore.getByIds([state.forcedSkillId], {
callerRoles: state.identity.roles,
});
const [skill] = await deps.skillStore.getByIds([state.forcedSkillId], callerFilter(state.identity));
if (skill) return { selectedSkill: skill };
}
if (state.forcedAgentId && deps.agentStore) {
const [found] = await deps.agentStore.getByIds([state.forcedAgentId], {
callerRoles: state.identity.roles,
});
const [found] = await deps.agentStore.getByIds([state.forcedAgentId], callerFilter(state.identity));
if (found) return { selectedAgent: found.agent };
}
return {};
Expand All @@ -1324,9 +1333,7 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
// selection -- a miss is never an error.
if (!state.activeSkillId || !state.identity) return {};
if (state.sessionSubject !== state.identity.subject) return {};
const [skill] = await deps.skillStore.getByIds([state.activeSkillId], {
callerRoles: state.identity.roles,
});
const [skill] = await deps.skillStore.getByIds([state.activeSkillId], callerFilter(state.identity));
if (!skill) return {};
const fits = await deps.skillFitChecker.fits(state.request, skill);
if (!fits) return {};
Expand Down Expand Up @@ -1392,7 +1399,7 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
// status === "complete": re-fetch the agent (RBAC re-check, same
// discipline as checkActiveAgentRun) and resume straight into
// delegation with it.
const [found] = await deps.agentStore.getByIds([pending.agentId], { callerRoles: state.identity.roles });
const [found] = await deps.agentStore.getByIds([pending.agentId], callerFilter(state.identity));
if (!found) return { pendingIdentityLink: undefined }; // agent gone/revoked -- fall through to fresh selection
return {
selectedAgent: found.agent,
Expand Down Expand Up @@ -1420,9 +1427,7 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
if (!deps.agentStore || !deps.agentChannel) return {};
if (!state.activeAgentRunId || !state.activeAgentId || !state.identity) return {};
if (state.sessionSubject !== state.identity.subject) return {};
const [found] = await deps.agentStore.getByIds([state.activeAgentId], {
callerRoles: state.identity.roles,
});
const [found] = await deps.agentStore.getByIds([state.activeAgentId], callerFilter(state.identity));
if (!found) return {};

try {
Expand Down Expand Up @@ -1520,7 +1525,7 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
if (!state.identity) return { skillCandidates: [] };
const skillCandidates = await deps.skillStore.query(
state.request,
{ callerRoles: state.identity.roles },
callerFilter(state.identity),
deps.skillTopK ?? 3,
);
return { skillCandidates };
Expand All @@ -1529,7 +1534,7 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
if (!deps.agentStore || !state.identity) return { agentCandidates: [] };
const agentCandidates = await deps.agentStore.query(
state.request,
{ callerRoles: state.identity.roles },
callerFilter(state.identity),
deps.agentTopK ?? 3,
);
return { agentCandidates };
Expand Down Expand Up @@ -1725,9 +1730,9 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
}

const [toolResults, agentResults] = await Promise.all([
toolIds.length > 0 ? deps.vectorStore.getByIds(toolIds, { callerRoles: state.identity.roles }) : [],
toolIds.length > 0 ? deps.vectorStore.getByIds(toolIds, callerFilter(state.identity)) : [],
agentIds.length > 0 && deps.agentStore
? deps.agentStore.getByIds(agentIds, { callerRoles: state.identity.roles })
? deps.agentStore.getByIds(agentIds, callerFilter(state.identity))
: [],
]);
// Adapt each resolved Agent into the same ToolDescriptor shape an
Expand All @@ -1743,6 +1748,7 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
name: r.agent.name,
description: r.agent.description,
allowedRoles: r.agent.allowedRoles,
allowedPrincipals: r.agent.allowedPrincipals,
tier: r.agent.tier,
agentRunTemplate: r.agent.agentRunTemplate,
identityProviders: r.agent.identityProviders,
Expand Down
15 changes: 15 additions & 0 deletions apps/agent-orchestrator/src/agents/crd-agent-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ describe("CrdAgentRegistry", () => {
expect(agents[0]?.id).toBe("software-engineering-agent");
});

it("carries Agent.spec.allowedPrincipals through for ABAC private-scoping (docs/adr/0037), and omits it when absent", async () => {
const privateAgent: AgentCustomResource = {
metadata: { name: "private-agent" },
spec: { ...validAgent.spec, allowedPrincipals: ["github:owner"] },
};
const listNamespacedCustomObject = vi.fn().mockResolvedValue({ items: [privateAgent, validAgent] });
const api: CustomObjectsApiLike = { listNamespacedCustomObject };
const registry = new CrdAgentRegistry("default", "core.controller-agent.dev", "v1alpha1", api);

const agents = await registry.listAll();

expect(agents[0]?.allowedPrincipals).toEqual(["github:owner"]);
expect(agents[1]?.allowedPrincipals).toBeUndefined();
});

it("returns an empty catalog when there are zero Agent resources", async () => {
const listNamespacedCustomObject = vi.fn().mockResolvedValue({ items: [] });
const api: CustomObjectsApiLike = { listNamespacedCustomObject };
Expand Down
3 changes: 3 additions & 0 deletions apps/agent-orchestrator/src/agents/crd-agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface AgentCustomResource {
input: string;
output: string;
allowedRoles: string[];
/** ABAC private-scoping (docs/adr/0037 — `Agent.spec.allowedPrincipals`). */
allowedPrincipals?: string[];
tier?: string;
orchestratorPrompt?: string;
/** Mirrors AgentDescriptor.identityProviders — see that field's doc comment. */
Expand Down Expand Up @@ -108,6 +110,7 @@ export function toAgentDescriptor(cr: AgentCustomResource, namespace: string): A
name,
description: `${spec.description}\n\nInput: ${spec.input}\nOutput: ${spec.output}`,
allowedRoles: spec.allowedRoles ?? [],
allowedPrincipals: spec.allowedPrincipals,
tier: spec.tier,
orchestratorPrompt: spec.orchestratorPrompt,
identityProviders: spec.identityProviders,
Expand Down
46 changes: 44 additions & 2 deletions apps/agent-orchestrator/src/agents/qdrant-agent-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ describe("QdrantAgentStore", () => {
name: "software-engineering-agent",
description: agent.description,
allowedRoles: ["writer"],
allowedPrincipals: [],
private: false,
tier: "privileged",
orchestratorPrompt: "Delegate the whole request verbatim as the goal.",
identityProviders: null,
Expand Down Expand Up @@ -139,12 +141,24 @@ describe("QdrantAgentStore", () => {
} as unknown as QdrantClient;
const store = new QdrantAgentStore({ url: "http://q", collection: "agents", vectorSize: 3 }, fakeEmbedder(), client);

const results = await store.query("build a feature", { callerRoles: ["writer"] });
const results = await store.query("build a feature", { callerRoles: ["writer"], callerPrincipal: "github:octocat" });

// RBAC (allowedRoles) AND ABAC (public OR names the caller's principal,
// docs/adr/0037) — both under `must`.
expect(client.search).toHaveBeenCalledWith("agents", {
vector: [0.1, 0.2, 0.3],
limit: 5,
filter: { must: [{ key: "allowedRoles", match: { any: ["writer"] } }] },
filter: {
must: [
{ key: "allowedRoles", match: { any: ["writer"] } },
{
should: [
{ key: "private", match: { value: false } },
{ key: "allowedPrincipals", match: { any: ["github:octocat"] } },
],
},
],
},
});
expect(results).toEqual([{ agent, score: 0.9 }]);
});
Expand Down Expand Up @@ -180,6 +194,34 @@ describe("QdrantAgentStore", () => {
expect(await store.getByIds(["software-engineering-agent"], { callerRoles: ["writer"] })).toEqual([]);
});

it("getByIds enforces ABAC private-scoping on top of roles (docs/adr/0037)", async () => {
const client = {
retrieve: vi.fn().mockResolvedValue([
{
payload: {
id: "software-engineering-agent",
name: "software-engineering-agent",
description: agent.description,
allowedRoles: ["writer"],
allowedPrincipals: ["github:owner"],
private: true,
tier: null,
orchestratorPrompt: null,
namespace: "default",
agentRef: "software-engineering-agent",
},
},
]),
} as unknown as QdrantClient;
const store = new QdrantAgentStore({ url: "http://q", collection: "agents", vectorSize: 3 }, fakeEmbedder(), client);

// Right role, wrong principal -> denied.
expect(await store.getByIds(["software-engineering-agent"], { callerRoles: ["writer"], callerPrincipal: "github:intruder" })).toEqual([]);
// Right role AND listed principal -> allowed.
const ok = await store.getByIds(["software-engineering-agent"], { callerRoles: ["writer"], callerPrincipal: "github:owner" });
expect(ok[0]?.agent.allowedPrincipals).toEqual(["github:owner"]);
});

it("delete maps domain ids to Qdrant point ids", async () => {
const client = { delete: vi.fn().mockResolvedValue(true) } as unknown as QdrantClient;
const store = new QdrantAgentStore({ url: "http://q", collection: "agents", vectorSize: 3 }, fakeEmbedder(), client);
Expand Down
17 changes: 16 additions & 1 deletion apps/agent-orchestrator/src/agents/qdrant-agent-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { QdrantClient } from "@qdrant/js-client-rest";
import { abacMustClause, abacPayload, principalAllowed } from "../vector-store/qdrant-abac.js";
import { toQdrantPointId } from "../vector-store/qdrant-id.js";
import type { Embedder } from "../vector-store/types.js";
import type { AgentDescriptor, AgentQueryFilter, AgentSearchResult, AgentStore } from "./types.js";
Expand All @@ -22,6 +23,10 @@ interface AgentPayload {
name: string;
description: string;
allowedRoles: string[];
/** ABAC private-scope list (docs/adr/0037); empty when the agent is public. */
allowedPrincipals: string[];
/** Denormalized `allowedPrincipals.length > 0`, matched by the query filter. */
private: boolean;
tier: string | null;
orchestratorPrompt: string | null;
identityProviders: string[] | null;
Expand Down Expand Up @@ -72,6 +77,7 @@ export class QdrantAgentStore implements AgentStore {
name: agent.name,
description: agent.description,
allowedRoles: agent.allowedRoles,
...abacPayload(agent.allowedPrincipals),
tier: agent.tier ?? null,
orchestratorPrompt: agent.orchestratorPrompt ?? null,
identityProviders: agent.identityProviders ?? null,
Expand All @@ -92,8 +98,13 @@ export class QdrantAgentStore implements AgentStore {
const results = await this.client.search(this.cfg.collection, {
vector,
limit: k,
// RBAC (allowedRoles intersect) AND ABAC (public OR names the caller's
// principal, docs/adr/0037) — both required, both under `must`.
filter: {
must: [{ key: "allowedRoles", match: { any: filter.callerRoles } }],
must: [
{ key: "allowedRoles", match: { any: filter.callerRoles } },
abacMustClause(filter.callerPrincipal),
],
},
});
return results.map((point) => ({ agent: toAgentDescriptor(point.payload as unknown as AgentPayload), score: point.score }));
Expand All @@ -113,6 +124,9 @@ export class QdrantAgentStore implements AgentStore {
const payload = point.payload as unknown as AgentPayload | undefined;
if (!payload) continue;
if (!payload.allowedRoles.some((role) => filter.callerRoles.includes(role))) continue;
// ABAC (docs/adr/0037): a private agent is only resolvable by a caller it
// names, same fail-closed discipline as the RBAC check above.
if (!principalAllowed(payload.allowedPrincipals, filter.callerPrincipal)) continue;
results.push({ agent: toAgentDescriptor(payload), score: 1 });
}
return results;
Expand All @@ -129,6 +143,7 @@ function toAgentDescriptor(payload: AgentPayload): AgentDescriptor {
name: payload.name,
description: payload.description,
allowedRoles: payload.allowedRoles,
allowedPrincipals: payload.allowedPrincipals ?? undefined,
tier: payload.tier ?? undefined,
orchestratorPrompt: payload.orchestratorPrompt ?? undefined,
identityProviders: payload.identityProviders ?? undefined,
Expand Down
17 changes: 17 additions & 0 deletions apps/agent-orchestrator/src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export interface AgentDescriptor {
description: string;
/** Roles/scopes allowed to select this agent; enforced as a retrieval filter (same discipline as Tool). */
allowedRoles: string[];
/**
* ABAC private-scoping (docs/adr/0037): when non-empty, this agent is
* PRIVATE — only a caller whose resolved principal is in this list may
* retrieve/select it, layered ON TOP of {@link allowedRoles}. Absent/empty
* means no ABAC restriction (RBAC-only). Mirrors
* `Agent.spec.allowedPrincipals` and {@link ToolDescriptor.allowedPrincipals}.
*/
allowedPrincipals?: string[];
/** Optional coarse risk/cost tier, mirrors ToolDescriptor.tier. */
tier?: string;
/**
Expand Down Expand Up @@ -59,6 +67,15 @@ export interface AgentRunTemplate {
export interface AgentQueryFilter {
/** Only agents whose `allowedRoles` intersects this set are returned. */
callerRoles: string[];
/**
* The caller's resolved principal (docs/adr/0030 §6 — `identity.principal`,
* falling back to `identity.subject`), used to enforce ABAC private-scoping
* (docs/adr/0037): an agent with a non-empty `allowedPrincipals` is returned
* only when this value is one of them, layered on top of the `callerRoles`
* check. Always supplied by the graph (a subject is always present), so a
* private agent fails closed when it doesn't match.
*/
callerPrincipal: string;
}

export interface AgentSearchResult {
Expand Down
Loading