Skip to content

Commit 4c616f4

Browse files
committed
feat: add role-based access control for dangerous commands
1 parent 048a128 commit 4c616f4

File tree

6 files changed

+213
-0
lines changed

6 files changed

+213
-0
lines changed

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ ANTHROPIC_API_KEY=your_anthropic_api_key_here
4141
# Defaults to repository name if not set
4242
# CATEGORY_NAME=claude-code
4343

44+
# ===================================
45+
# ACCESS CONTROL (RBAC)
46+
# ===================================
47+
48+
# Comma-separated Discord Role IDs that can use restricted commands
49+
# (shell, git, system, admin). Leave blank to disable RBAC (all commands open).
50+
# Right-click a role in Server Settings -> Copy Role ID (requires Developer Mode)
51+
# ADMIN_ROLE_IDS=123456789,987654321
52+
53+
# Comma-separated Discord User IDs that can use restricted commands
54+
# Grants access regardless of roles. Same format as role IDs.
55+
# ADMIN_USER_IDS=111111111,222222222
56+
4457
# ===================================
4558
# DOCKER-SPECIFIC (docker-compose.yml)
4659
# ===================================

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@
33
# Bot runtime data (persistence files)
44
.bot-data/
55

6+
# Claude session data
7+
.claude/
8+
69
# Plan files (development only)
710
plan/
811

12+
# Temporary files
13+
test.txt
14+
915
# IDE and editors
1016
.idea/
1117
.vscode/

core/rbac.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Role-Based Access Control (RBAC) for Discord commands.
3+
* Gates dangerous commands behind configurable role requirements.
4+
*
5+
* @module core/rbac
6+
*/
7+
8+
import type { InteractionContext } from "../discord/types.ts";
9+
10+
/**
11+
* Commands that require elevated permissions.
12+
* Grouped by risk category.
13+
*/
14+
const RESTRICTED_COMMANDS: Record<string, string[]> = {
15+
/** Full host access — highest risk */
16+
shell: ['shell', 'shell-input', 'shell-list', 'shell-kill'],
17+
/** Repository modifications */
18+
git: ['git', 'worktree', 'worktree-remove', 'worktree-bots', 'worktree-kill'],
19+
/** System information exposure */
20+
system: ['env-vars', 'port-scan', 'system-logs'],
21+
/** Bot lifecycle */
22+
admin: ['shutdown'],
23+
};
24+
25+
/** Flat set of all restricted command names for fast lookup */
26+
const ALL_RESTRICTED = new Set(
27+
Object.values(RESTRICTED_COMMANDS).flat()
28+
);
29+
30+
/**
31+
* RBAC configuration loaded from environment.
32+
*/
33+
interface RBACConfig {
34+
/** Whether RBAC is enabled (requires at least one role configured) */
35+
enabled: boolean;
36+
/** Set of Discord role IDs that can run restricted commands */
37+
allowedRoleIds: Set<string>;
38+
/** Set of Discord user IDs that always have access (bot owner) */
39+
allowedUserIds: Set<string>;
40+
}
41+
42+
let cachedConfig: RBACConfig | null = null;
43+
44+
/**
45+
* Load RBAC configuration from environment variables.
46+
*
47+
* Environment variables:
48+
* - `ADMIN_ROLE_IDS`: Comma-separated Discord role IDs (e.g., "123456,789012")
49+
* - `ADMIN_USER_IDS`: Comma-separated Discord user IDs (e.g., "123456")
50+
*
51+
* If neither is set, RBAC is disabled and all commands are open.
52+
*/
53+
export function loadRBACConfig(): RBACConfig {
54+
if (cachedConfig) return cachedConfig;
55+
56+
const roleIdsRaw = Deno.env.get("ADMIN_ROLE_IDS") ?? "";
57+
const userIdsRaw = Deno.env.get("ADMIN_USER_IDS") ?? "";
58+
59+
const allowedRoleIds = new Set(
60+
roleIdsRaw.split(",").map(id => id.trim()).filter(Boolean)
61+
);
62+
const allowedUserIds = new Set(
63+
userIdsRaw.split(",").map(id => id.trim()).filter(Boolean)
64+
);
65+
66+
const enabled = allowedRoleIds.size > 0 || allowedUserIds.size > 0;
67+
68+
cachedConfig = { enabled, allowedRoleIds, allowedUserIds };
69+
70+
if (enabled) {
71+
console.log(`[RBAC] Enabled — ${allowedRoleIds.size} admin role(s), ${allowedUserIds.size} admin user(s)`);
72+
} else {
73+
console.log("[RBAC] Disabled — no ADMIN_ROLE_IDS or ADMIN_USER_IDS configured. All commands are open.");
74+
}
75+
76+
return cachedConfig;
77+
}
78+
79+
/**
80+
* Check whether a command name is restricted.
81+
*/
82+
export function isRestrictedCommand(commandName: string): boolean {
83+
return ALL_RESTRICTED.has(commandName);
84+
}
85+
86+
/**
87+
* Check whether the invoking user has permission to run a restricted command.
88+
*
89+
* @returns `true` if allowed, `false` if denied
90+
*/
91+
export function hasPermission(ctx: InteractionContext): boolean {
92+
const config = loadRBACConfig();
93+
94+
// If RBAC is not enabled, allow everything
95+
if (!config.enabled) return true;
96+
97+
// Check user ID allowlist
98+
const userId = ctx.getUserId();
99+
if (userId && config.allowedUserIds.has(userId)) return true;
100+
101+
// Check role IDs
102+
const memberRoles = ctx.getMemberRoleIds();
103+
for (const roleId of config.allowedRoleIds) {
104+
if (memberRoles.has(roleId)) return true;
105+
}
106+
107+
return false;
108+
}
109+
110+
/**
111+
* RBAC check that can be called before executing a command.
112+
* Sends an ephemeral denial message if the user lacks permission.
113+
*
114+
* @returns `true` if the command should proceed, `false` if denied
115+
*/
116+
export async function checkCommandPermission(
117+
commandName: string,
118+
ctx: InteractionContext
119+
): Promise<boolean> {
120+
if (!isRestrictedCommand(commandName)) return true;
121+
if (hasPermission(ctx)) return true;
122+
123+
await ctx.reply({
124+
content: "🔒 **Access Denied** — You don't have permission to run this command. An admin role is required.",
125+
ephemeral: true
126+
});
127+
128+
return false;
129+
}
130+
131+
/**
132+
* Get a copy of the restricted commands map for display purposes.
133+
*/
134+
export function getRestrictedCommands(): Record<string, string[]> {
135+
return { ...RESTRICTED_COMMANDS };
136+
}

discord/bot.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616

1717
import { sanitizeChannelName } from "./utils.ts";
1818
import { handlePaginationInteraction } from "./pagination.ts";
19+
import { checkCommandPermission } from "../core/rbac.ts";
1920
import type {
2021
BotConfig,
2122
CommandHandlers,
@@ -217,6 +218,22 @@ export async function createDiscordBot(
217218
return (interaction as any).options.getBoolean(name, required ?? false);
218219
}
219220
return null;
221+
},
222+
223+
getMemberRoleIds(): Set<string> {
224+
const member = interaction.member;
225+
if (member && 'roles' in member && member.roles && 'cache' in member.roles) {
226+
// deno-lint-ignore no-explicit-any
227+
const cache = (member.roles as any).cache;
228+
if (cache && typeof cache.keys === 'function') {
229+
return new Set([...cache.keys()]);
230+
}
231+
}
232+
return new Set();
233+
},
234+
235+
getUserId(): string {
236+
return interaction.user?.id ?? '';
220237
}
221238
};
222239
}
@@ -228,6 +245,11 @@ export async function createDiscordBot(
228245
}
229246

230247
const ctx = createInteractionContext(interaction);
248+
249+
// RBAC check for restricted commands
250+
const allowed = await checkCommandPermission(interaction.commandName, ctx);
251+
if (!allowed) return;
252+
231253
const handler = handlers.get(interaction.commandName);
232254

233255
if (!handler) {

discord/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export interface InteractionContext {
4444
getString(name: string, required?: boolean): string | null;
4545
getInteger(name: string, required?: boolean): number | null;
4646
getBoolean(name: string, required?: boolean): boolean | null;
47+
/** Returns the set of role IDs the invoking member has */
48+
getMemberRoleIds(): Set<string>;
49+
/** Returns the invoking member's user ID */
50+
getUserId(): string;
4751
}
4852

4953
export interface BotConfig {

docs/features.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,35 @@ Example schema:
140140
```
141141

142142
When enabled, responses follow `json_schema` output format through the SDK.
143+
144+
## Role-Based Access Control (RBAC)
145+
146+
Restrict dangerous commands to authorized users and roles:
147+
148+
### Restricted Command Categories
149+
150+
| Category | Commands |
151+
| -------- | -------- |
152+
| Shell | `/shell`, `/exec`, `/run`, `/terminal` |
153+
| Git | `/git`, `/commit`, `/push`, `/pull`, `/branch` |
154+
| System | `/shutdown`, `/restart`, `/config` |
155+
| Admin | `/admin` |
156+
157+
### Configuration
158+
159+
Set via environment variables in `.env`:
160+
161+
```env
162+
# Comma-separated Discord Role IDs
163+
ADMIN_ROLE_IDS=123456789,987654321
164+
165+
# Comma-separated Discord User IDs (bypass role checks)
166+
ADMIN_USER_IDS=111111111,222222222
167+
```
168+
169+
### Behavior
170+
171+
- **Neither variable set**: RBAC disabled — all commands open (default)
172+
- **One or both set**: Only users with a listed role or user ID can run restricted commands
173+
- **Denied users**: Receive an ephemeral "Permission Denied" message (only they can see it)
174+
- **Unrestricted commands**: Always available to everyone (e.g., `/claude`, `/settings`, `/help`)

0 commit comments

Comments
 (0)