refactor: shared source — replace per-group agent-runner copies with single RO mount
Replace the per-group agent-runner-src copy model with a single shared read-only mount. Source and skills are now RO + shared; personality, config, working files, and Claude state stay RW + per-group. Key changes: - Mount container/agent-runner/src/ RO at /app/src (all groups share one copy) - Mount container/skills/ RO at /app/skills; per-group skill selection via symlinks in .claude-shared/skills/ based on container.json "skills" field - Mount container.json as nested RO bind on top of RW group dir - Move all NANOCLAW_* env vars to container.json (runner reads at startup) - New runner config.ts module replaces process.env reads - Move command gate (filtered/admin) from container to host router - Dockerfile: remove source COPY, split CLI installs (claude-code last), move agent-runner deps above CLIs for better layer caching - Add writeOutboundDirect for router denial responses - Design doc at docs/shared-src.md Not included (follow-up): DB migration to drop agent_provider columns, cleanup of orphaned agent-runner-src directories. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
src/command-gate.ts
Normal file
70
src/command-gate.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Host-side command gate. Classifies inbound slash commands and gates
|
||||
* them before they reach the container.
|
||||
*
|
||||
* - Filtered commands: dropped silently (never reach the container)
|
||||
* - Admin commands: checked against user_roles; denied senders get a
|
||||
* "Permission denied" response written directly to messages_out
|
||||
* - Normal messages: pass through unchanged
|
||||
*/
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
|
||||
export type GateResult =
|
||||
| { action: 'pass' }
|
||||
| { action: 'filter' }
|
||||
| { action: 'deny'; command: string };
|
||||
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
|
||||
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']);
|
||||
|
||||
/**
|
||||
* Classify a message and decide whether it should reach the container.
|
||||
* Returns 'pass' for normal messages and authorized admin commands,
|
||||
* 'filter' for silently-dropped commands, 'deny' for unauthorized
|
||||
* admin commands.
|
||||
*/
|
||||
export function gateCommand(
|
||||
content: string,
|
||||
userId: string | null,
|
||||
agentGroupId: string,
|
||||
): GateResult {
|
||||
let text: string;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
text = (parsed.text || '').trim();
|
||||
} catch {
|
||||
text = content.trim();
|
||||
}
|
||||
|
||||
if (!text.startsWith('/')) return { action: 'pass' };
|
||||
|
||||
const command = text.split(/\s/)[0].toLowerCase();
|
||||
|
||||
if (FILTERED_COMMANDS.has(command)) return { action: 'filter' };
|
||||
|
||||
if (ADMIN_COMMANDS.has(command)) {
|
||||
if (isAdmin(userId, agentGroupId)) {
|
||||
return { action: 'pass' };
|
||||
}
|
||||
return { action: 'deny', command };
|
||||
}
|
||||
|
||||
// Unknown slash commands pass through (the agent/SDK handles them)
|
||||
return { action: 'pass' };
|
||||
}
|
||||
|
||||
function isAdmin(userId: string | null, agentGroupId: string): boolean {
|
||||
if (!userId) return false;
|
||||
if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND (agent_group_id IS NULL OR agent_group_id = ?)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId, agentGroupId);
|
||||
return row != null;
|
||||
}
|
||||
Reference in New Issue
Block a user