404 lines
11 KiB
Markdown
404 lines
11 KiB
Markdown
# Agent Permissions Plugin - Implementation Plan
|
|
|
|
**Status:** ✅ Complete (March 16, 2026)
|
|
|
|
## Overview
|
|
|
|
This plugin provides per-agent permission toggling, allowing fine-grained control over what actions each agent can perform within Paperclip.
|
|
|
|
## Summary of Completed Work
|
|
|
|
- ✅ Plugin scaffolded with manifest, worker, and UI entry points
|
|
- ✅ Worker implements 3 handlers: `agent-permissions`, `all-agents-permissions`, `toggle-agent-permission`
|
|
- ✅ UI components: `AgentPermissionsTab` (detail tab) and `PermissionsNav` (sidebar)
|
|
- ✅ 6 permission keys implemented with proper validation
|
|
- ✅ 18 unit tests passing
|
|
- ✅ Build, typecheck all successful
|
|
- ✅ Constants extracted to shared module for DRY code
|
|
|
|
## 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
|