feat(agentstack): add delegation authority checks (#87)

This commit is contained in:
Qyra-One 2026-07-01 04:14:40 -04:00 committed by GitHub
parent c495041adb
commit 0bca203527
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 27 deletions

View file

@ -74,7 +74,8 @@ Backed by `src/index.ts`, `src/types.ts`, `src/manifest.ts`:
- **Status lifecycle**`pending → queued → running → blocked → complete | failed | cancelled`. - **Status lifecycle**`pending → queued → running → blocked → complete | failed | cancelled`.
- **`AgentStack` coordinator** (in-memory `Map` storage, injectable clock): - **`AgentStack` coordinator** (in-memory `Map` storage, injectable clock):
`registerAgent`, `getAgent`, `createTask`, `getTask`, `assignTask`, `updateTaskStatus`, `registerAgent`, `getAgent`, `createTask`, `getTask`, `assignTask`, `updateTaskStatus`,
`delegate`, `revokeDelegation`, `listTasks`, `snapshot`, and an `on(listener)` event subscription. `delegate`, `revokeDelegation`, `listDelegations`, `hasDelegation`, `listTasks`, `snapshot`,
and an `on(listener)` event subscription.
- **Events**`agent.registered`, `task.created`, `task.assigned`, `task.updated`, - **Events**`agent.registered`, `task.created`, `task.assigned`, `task.updated`,
`delegation.granted`, `delegation.revoked`. `delegation.granted`, `delegation.revoked`.
- **`agentStackPlugin`** — a LogicSRC `PluginDefinition`, plus `agentStackManifest` declaring - **`agentStackPlugin`** — a LogicSRC `PluginDefinition`, plus `agentStackManifest` declaring

View file

@ -15,7 +15,8 @@ Shared AppKit OpenSpec (`profullstack-web/openspec/specs/agentstack`).
(`pending → queued → running → blocked → complete | failed | cancelled`) that can bind to (`pending → queued → running → blocked → complete | failed | cancelled`) that can bind to
payment, escrow, and reputation events. payment, escrow, and reputation events.
- **`AgentStack`** — an in-memory coordinator that registers agents, tracks tasks, records - **`AgentStack`** — an in-memory coordinator that registers agents, tracks tasks, records
delegation grants, and emits coordination events. Storage backends can wrap the same API. delegation grants, checks active scoped authority, and emits coordination events. Storage
backends can wrap the same API.
- **`agentStackPlugin`** — a LogicSRC `PluginDefinition` (validated against the plugin - **`agentStackPlugin`** — a LogicSRC `PluginDefinition` (validated against the plugin
manifest schema) exposing AgentStack as a coordination plugin with routes, events, manifest schema) exposing AgentStack as a coordination plugin with routes, events,
permissions, and a TUI panel. permissions, and a TUI panel.
@ -39,6 +40,10 @@ const task = stack.createTask({ ownerDid: owner, sourceApp: "sh1pt.com", title:
stack.assignTask(task.id, agentDid("abc")); stack.assignTask(task.id, agentDid("abc"));
stack.updateTaskStatus(task.id, "running"); stack.updateTaskStatus(task.id, "running");
stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" }); stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" });
const grant = stack.delegate(owner, agentDid("abc"), ["tasks:create"]);
stack.hasDelegation(owner, agentDid("abc"), "tasks:create"); // true while the grant is active
stack.revokeDelegation(grant.id);
``` ```
## Cross-app identity ## Cross-app identity

View file

@ -120,15 +120,38 @@ describe("AgentStack coordinator", () => {
it("records delegation grants and emits events", () => { it("records delegation grants and emits events", () => {
const stack = new AgentStack(); const stack = new AgentStack();
const listener = vi.fn(); const listener = vi.fn();
stack.on(listener); stack.on(listener);
stack.registerAgent(agent); stack.registerAgent(agent);
const grant = stack.delegate(owner, agent.did, ["tasks:create"]); const grant = stack.delegate(owner, agent.did, ["tasks:create"]);
expect(grant.ownerDid).toBe(owner); expect(grant.ownerDid).toBe(owner);
expect(grant.agentDid).toBe(agent.did); expect(grant.agentDid).toBe(agent.did);
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" })); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" }));
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" })); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" }));
}); });
it("checks active delegation authority by owner, agent, scope, and expiry", () => {
const stack = new AgentStack(() => "2026-01-01T00:00:00.000Z");
stack.registerAgent(agent);
stack.delegate(owner, agent.did, ["tasks:create"], "2026-01-02T00:00:00.000Z");
expect(stack.hasDelegation(owner, agent.did, "tasks:create")).toBe(true);
expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(false);
expect(stack.hasDelegation(owner, agent.did, "tasks:create", "2026-01-03T00:00:00.000Z")).toBe(false);
expect(stack.listDelegations({ ownerDid: owner, agentDid: agent.did, scope: "tasks:create" })).toHaveLength(1);
expect(stack.listDelegations({ activeAt: "2026-01-03T00:00:00.000Z" })).toHaveLength(0);
});
it("allows wildcard delegation scopes and ignores revoked grants", () => {
const stack = new AgentStack();
stack.registerAgent(agent);
const grant = stack.delegate(owner, agent.did, ["*"]);
expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(true);
stack.revokeDelegation(grant.id);
expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(false);
});
it("rejects unknown agents and invalid DIDs", () => { it("rejects unknown agents and invalid DIDs", () => {
const stack = new AgentStack(); const stack = new AgentStack();

View file

@ -178,18 +178,46 @@ export class AgentStack {
return grant; return grant;
} }
revokeDelegation(grantId: string): DelegationGrant { revokeDelegation(grantId: string): DelegationGrant {
const grant = this.delegations.get(grantId); const grant = this.delegations.get(grantId);
if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`); if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`);
this.delegations.delete(grantId); this.delegations.delete(grantId);
this.emit({ type: "delegation.revoked", grant }); this.emit({ type: "delegation.revoked", grant });
return grant; return grant;
} }
listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] { listDelegations(filter?: {
return [...this.tasks.values()].filter((task) => { ownerDid?: string;
if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false; agentDid?: string;
if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false; scope?: string;
activeAt?: string;
}): DelegationGrant[] {
return [...this.delegations.values()].filter((grant) => {
if (filter?.ownerDid && grant.ownerDid !== filter.ownerDid) return false;
if (filter?.agentDid && grant.agentDid !== filter.agentDid) return false;
if (filter?.scope && !this.grantAllowsScope(grant, filter.scope)) return false;
if (filter?.activeAt && !this.grantIsActive(grant, filter.activeAt)) return false;
return true;
});
}
hasDelegation(ownerDidValue: string, agentDidValue: string, scope: string, at: string = this.now()): boolean {
if (!parseDid(ownerDidValue)) throw new Error(`Invalid owner DID: ${ownerDidValue}`);
if (!this.agents.has(agentDidValue)) throw new Error(`Unknown agent: ${agentDidValue}`);
return (
this.listDelegations({
ownerDid: ownerDidValue,
agentDid: agentDidValue,
scope,
activeAt: at
}).length > 0
);
}
listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] {
return [...this.tasks.values()].filter((task) => {
if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false;
if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false;
if (filter?.status && task.status !== filter.status) return false; if (filter?.status && task.status !== filter.status) return false;
return true; return true;
}); });
@ -205,10 +233,18 @@ export class AgentStack {
private requireTask(id: string): DidTask { private requireTask(id: string): DidTask {
const task = this.tasks.get(id); const task = this.tasks.get(id);
if (!task) throw new Error(`Unknown task: ${id}`); if (!task) throw new Error(`Unknown task: ${id}`);
return task; return task;
} }
}
private grantAllowsScope(grant: DelegationGrant, scope: string): boolean {
return grant.scopes.includes(scope) || grant.scopes.includes("*");
}
private grantIsActive(grant: DelegationGrant, at: string): boolean {
return !grant.expiresAt || grant.expiresAt > at;
}
}
/** LogicSRC plugin definition exposing AgentStack as a coordination plugin. */ /** LogicSRC plugin definition exposing AgentStack as a coordination plugin. */
export const agentStackPlugin: PluginDefinition = { export const agentStackPlugin: PluginDefinition = {