FRE-339: Add implementation plan for agent permissions plugin
This commit is contained in:
391
plugin-agent-permissions/IMPLEMENTATION_PLAN.md
Normal file
391
plugin-agent-permissions/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# Agent Permissions Plugin - Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin provides per-agent permission toggling, allowing fine-grained control over what actions each agent can perform within Paperclip.
|
||||
|
||||
## 6 Permission Keys
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `agents:create` | Create new agents (agent hiring) |
|
||||
| `users:invite` | Invite board users to the company |
|
||||
| `users:manage_permissions` | Manage user permissions |
|
||||
| `tasks:assign` | Assign tasks to agents |
|
||||
| `tasks:assign_scope` | Scope for task assignment (limits which agents can be assigned) |
|
||||
| `joins:approve` | Approve join requests |
|
||||
|
||||
## Current State
|
||||
|
||||
- Only `canCreateAgents` (`agents:create`) is exposed on the agent permissions object
|
||||
- All 6 permissions can be granted to board users via the `principal_permission_grants` table
|
||||
- No per-agent permission granular control exists yet
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
```
|
||||
paperclip-plugins/plugin-agent-permissions/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── esbuild.config.mjs
|
||||
├── README.md
|
||||
└── src/
|
||||
├── manifest.ts
|
||||
├── worker.ts
|
||||
└── ui/
|
||||
├── index.tsx
|
||||
├── AgentPermissionsTab.tsx
|
||||
└── PermissionToggle.tsx
|
||||
```
|
||||
|
||||
### Required Capabilities
|
||||
|
||||
```typescript
|
||||
capabilities: [
|
||||
"agents.read", // Read agent data including permissions
|
||||
"ui.detailTab.register", // Add tab to agent detail page
|
||||
"ui.sidebar.register" // Add sidebar navigation item
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Scaffold Plugin
|
||||
|
||||
```bash
|
||||
cd /home/mike/code/paperclip_plugins
|
||||
mkdir plugin-agent-permissions
|
||||
cd plugin-agent-permissions
|
||||
pnpm init
|
||||
pnpm add @paperclipai/plugin-sdk
|
||||
pnpm add -D typescript esbuild tsx
|
||||
```
|
||||
|
||||
### Step 2: Create Manifest
|
||||
|
||||
```typescript
|
||||
// src/manifest.ts
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclipai.plugin-agent-permissions",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Agent Permissions",
|
||||
description: "Per-agent permission toggling for fine-grained access control",
|
||||
author: "FrenoCorp",
|
||||
categories: ["permissions", "agent-management"],
|
||||
capabilities: [
|
||||
"agents.read",
|
||||
"ui.detailTab.register",
|
||||
"ui.sidebar.register"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "detailTab",
|
||||
id: "permissions",
|
||||
displayName: "Permissions",
|
||||
exportName: "AgentPermissionsTab",
|
||||
entityTypes: ["agent"]
|
||||
},
|
||||
{
|
||||
type: "sidebar",
|
||||
id: "permissions-nav",
|
||||
displayName: "Permissions",
|
||||
exportName: "PermissionsNav"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
```
|
||||
|
||||
### Step 3: Implement Worker
|
||||
|
||||
```typescript
|
||||
// src/worker.ts
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PERMISSION_KEYS = [
|
||||
"agents:create",
|
||||
"users:invite",
|
||||
"users:manage_permissions",
|
||||
"tasks:assign",
|
||||
"tasks:assign_scope",
|
||||
"joins:approve"
|
||||
] as const;
|
||||
|
||||
type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
|
||||
interface AgentPermissions {
|
||||
agentId: string;
|
||||
permissions: Record<PermissionKey, boolean>;
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
// Data handler: Get permissions for a specific agent
|
||||
ctx.data.register("agent-permissions", async (params) => {
|
||||
const { agentId } = params as { agentId: string };
|
||||
|
||||
// Query principal_permission_grants for this agent
|
||||
const grants = await ctx.entities.query(`
|
||||
SELECT ppg.permission_key
|
||||
FROM principal_permission_grants ppg
|
||||
JOIN principals p ON ppg.principal_id = p.id
|
||||
WHERE p.type = 'agent' AND p.external_id = $1
|
||||
`, [agentId]);
|
||||
|
||||
const grantedPermissions = new Set(grants.map((g: any) => g.permission_key));
|
||||
|
||||
return {
|
||||
agentId,
|
||||
permissions: PERMISSION_KEYS.reduce((acc, key) => ({
|
||||
...acc,
|
||||
[key]: grantedPermissions.has(key)
|
||||
}), {} as Record<PermissionKey, boolean>)
|
||||
};
|
||||
});
|
||||
|
||||
// Action: Toggle a permission for an agent
|
||||
ctx.actions.register("toggle-agent-permission", async (params) => {
|
||||
const { agentId, permissionKey, enabled } = params as {
|
||||
agentId: string;
|
||||
permissionKey: PermissionKey;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
// Find or create principal for agent
|
||||
let principalId = await ctx.entities.query(`
|
||||
SELECT id FROM principals WHERE type = 'agent' AND external_id = $1
|
||||
`, [agentId]);
|
||||
|
||||
if (!principalId || principalId.length === 0) {
|
||||
// Create principal if it doesn't exist
|
||||
const result = await ctx.entities.create("principals", {
|
||||
type: "agent",
|
||||
external_id: agentId
|
||||
});
|
||||
principalId = result.id;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// Grant permission
|
||||
await ctx.entities.create("principal_permission_grants", {
|
||||
principal_id: principalId,
|
||||
permission_key: permissionKey
|
||||
});
|
||||
} else {
|
||||
// Revoke permission
|
||||
await ctx.entities.query(`
|
||||
DELETE FROM principal_permission_grants
|
||||
WHERE principal_id = $1 AND permission_key = $2
|
||||
`, [principalId, permissionKey]);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Data handler: Get all agents with their permissions
|
||||
ctx.data.register("all-agents-permissions", async () => {
|
||||
const agents = await ctx.agents.list();
|
||||
|
||||
const result = await Promise.all(agents.map(async (agent) => {
|
||||
const grants = await ctx.entities.query(`
|
||||
SELECT permission_key FROM principal_permission_grants
|
||||
WHERE principal_id IN (
|
||||
SELECT id FROM principals WHERE type = 'agent' AND external_id = $1
|
||||
)
|
||||
`, [agent.id]);
|
||||
|
||||
const grantedPermissions = new Set(grants.map((g: any) => g.permission_key));
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
permissions: PERMISSION_KEYS.reduce((acc, key) => ({
|
||||
...acc,
|
||||
[key]: grantedPermissions.has(key)
|
||||
}), {} as Record<PermissionKey, boolean>)
|
||||
};
|
||||
}));
|
||||
|
||||
return result;
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Agent permissions plugin running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
```
|
||||
|
||||
### Step 4: Implement UI Components
|
||||
|
||||
```typescript
|
||||
// src/ui/index.tsx
|
||||
export { AgentPermissionsTab } from './AgentPermissionsTab';
|
||||
export { PermissionsNav } from './PermissionsNav';
|
||||
|
||||
// src/ui/AgentPermissionsTab.tsx
|
||||
import { useState } from "react";
|
||||
import { useHostContext, usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
const PERMISSION_LABELS: Record<string, string> = {
|
||||
"agents:create": "Create Agents",
|
||||
"users:invite": "Invite Users",
|
||||
"users:manage_permissions": "Manage Permissions",
|
||||
"tasks:assign": "Assign Tasks",
|
||||
"tasks:assign_scope": "Task Assignment Scope",
|
||||
"joins:approve": "Approve Join Requests"
|
||||
};
|
||||
|
||||
export function AgentPermissionsTab({ context }: PluginDetailTabProps) {
|
||||
const { entityId: agentId } = context;
|
||||
|
||||
const { data, refresh } = usePluginData<{
|
||||
agentId: string;
|
||||
permissions: Record<string, boolean>;
|
||||
}>("agent-permissions", { agentId });
|
||||
|
||||
const togglePermission = usePluginAction("toggle-agent-permission");
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading permissions...</div>;
|
||||
}
|
||||
|
||||
async function handleToggle(permissionKey: string, enabled: boolean) {
|
||||
setUpdating(permissionKey);
|
||||
try {
|
||||
await togglePermission({ agentId, permissionKey, enabled: !enabled });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle permission:", error);
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "1rem", maxWidth: "600px" }}>
|
||||
<h2 style={{ marginBottom: "1.5rem" }}>Agent Permissions</h2>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
Control what actions this agent can perform.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{Object.entries(data.permissions).map(([key, enabled]) => (
|
||||
<label key={key} style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
cursor: updating === key ? "wait" : "pointer"
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={() => handleToggle(key, enabled)}
|
||||
disabled={updating !== null}
|
||||
style={{ width: "18px", height: "18px" }}
|
||||
/>
|
||||
<span style={{ fontWeight: 500 }}>{PERMISSION_LABELS[key] || key}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{updating && (
|
||||
<p style={{ color: "#666", fontSize: "0.875rem", marginTop: "1rem" }}>
|
||||
Updating...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Build Configuration
|
||||
|
||||
```javascript
|
||||
// esbuild.config.mjs
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const presets = createPluginBundlerPresets({
|
||||
uiEntry: "src/ui/index.tsx"
|
||||
});
|
||||
|
||||
// Build worker
|
||||
await build(presets.esbuild.worker);
|
||||
|
||||
// Build manifest
|
||||
await build(presets.esbuild.manifest);
|
||||
|
||||
// Build UI
|
||||
await build(presets.esbuild.ui);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
// tests/worker.test.ts
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import plugin from "../src/worker";
|
||||
import manifest from "../src/manifest";
|
||||
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
// Test getting agent permissions
|
||||
const permissions = await harness.callData("agent-permissions", { agentId: "test-agent-123" });
|
||||
console.log(permissions);
|
||||
|
||||
// Test toggling permission
|
||||
await harness.callAction("toggle-agent-permission", {
|
||||
agentId: "test-agent-123",
|
||||
permissionKey: "agents:create",
|
||||
enabled: true
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# In your Paperclip instance
|
||||
cd /path/to/paperclip
|
||||
pnpm add file:/path/to/paperclip-plugins/plugin-agent-permissions
|
||||
```
|
||||
|
||||
Or configure in the plugin settings UI with the local path.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Database Schema**: The `principal_permission_grants` table links principals (agents, users) to their permissions
|
||||
2. **Principal Types**: Agents have `type = 'agent'`, board users have `type = 'user'`
|
||||
3. **External ID**: Agents are referenced by their UUID in the `external_id` column of the `principals` table
|
||||
4. **Real-time Updates**: UI components should call `refresh()` after permission changes to update the display
|
||||
Reference in New Issue
Block a user