mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db59ddbe9c |
@@ -38,6 +38,8 @@ export interface ColumnDef {
|
||||
|
||||
export interface CustomOperation {
|
||||
access: Access;
|
||||
/** Operator-only: never runnable from inside a container (see CommandDef.hostOnly). */
|
||||
hostOnly?: boolean;
|
||||
description: string;
|
||||
args?: ColumnDef[];
|
||||
handler: (args: Record<string, unknown>, ctx: CallerContext) => Promise<unknown>;
|
||||
@@ -294,6 +296,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
|
||||
description: op.description,
|
||||
access: op.access,
|
||||
hostOnly: op.hostOnly,
|
||||
resource: def.plural,
|
||||
parseArgs: (raw) => normalizeArgs(raw),
|
||||
handler: async (args, ctx) => op.handler(args as Record<string, unknown>, ctx),
|
||||
|
||||
@@ -98,6 +98,16 @@ register({
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'host-only-cmd',
|
||||
description: 'test command (operator-only, like add-mount)',
|
||||
resource: 'groups',
|
||||
access: 'approval',
|
||||
hostOnly: true,
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
// Commands that return data shaped like real resources (for post-handler filtering tests)
|
||||
register({
|
||||
name: 'groups-list-data',
|
||||
@@ -178,6 +188,24 @@ function agentCtx(overrides?: Partial<Extract<CallerContext, { caller: 'agent' }
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('host-only commands (operator-only)', () => {
|
||||
it('rejects an agent caller even at global scope', async () => {
|
||||
// global scope is otherwise unrestricted — hostOnly must still reject.
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
const resp = await dispatch({ id: '1', command: 'host-only-cmd', args: {} }, agentCtx());
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('operator-only');
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the gate for a host (operator) caller', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'host-only-cmd', args: { x: 1 } }, { caller: 'host' });
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI scope enforcement', () => {
|
||||
it('disabled: rejects all CLI requests from agent', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
|
||||
|
||||
@@ -38,6 +38,14 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
return err(req.id, 'unknown-command', `no command "${req.command}"`);
|
||||
}
|
||||
|
||||
// Host-only commands (e.g. mount management) are operator-only: rejected for
|
||||
// ANY container caller, regardless of cli_scope (even `global`) or approval.
|
||||
// The mount allowlist is the boundary cli_scope itself lives inside, so an
|
||||
// agent must never alter it — not even with admin approval.
|
||||
if (cmd.hostOnly && ctx.caller !== 'host') {
|
||||
return err(req.id, 'forbidden', `"${req.command}" is operator-only and cannot be run from inside a container.`);
|
||||
}
|
||||
|
||||
// CLI scope enforcement for agent callers
|
||||
if (ctx.caller === 'agent') {
|
||||
const configRow = getContainerConfig(ctx.agentGroupId);
|
||||
|
||||
@@ -13,6 +13,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
name: string;
|
||||
description: string;
|
||||
access: Access;
|
||||
/**
|
||||
* Operator-only: rejected for ANY container (agent) caller regardless of
|
||||
* cli_scope (even `global`) or approval. For privileged host-boundary ops
|
||||
* like mount management — the mount allowlist is the boundary cli_scope
|
||||
* itself lives inside, so an agent must never be able to alter it.
|
||||
*/
|
||||
hostOnly?: boolean;
|
||||
/** Resource this command belongs to (for help grouping). */
|
||||
resource?: string;
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,7 @@ const TEST_DIR = '/tmp/nanoclaw-test-cli-groups';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, getDb } from '../../db/index.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
import { ensureContainerConfig, getContainerConfig } from '../../db/container-configs.js';
|
||||
// Side-effect import: registers the `groups-*` commands (including delete).
|
||||
import './groups.js';
|
||||
|
||||
@@ -218,3 +219,43 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
|
||||
expect((resp as { ok: false; error: { code: string; message: string } }).error.message).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groups config add-mount / remove-mount (host-only)', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('adds a mount idempotently and removes it (host caller)', async () => {
|
||||
const GID = 'ag-mount';
|
||||
createAgentGroup({ id: GID, name: 'm', folder: 'm', agent_provider: null, created_at: now() });
|
||||
ensureContainerConfig(GID);
|
||||
const args = { id: GID, host: '/data/.gmail-mcp', container: '/home/node/.gmail-mcp', ro: true };
|
||||
|
||||
const add = await dispatch({ id: 'r1', command: 'groups-config-add-mount', args }, { caller: 'host' });
|
||||
expect(add.ok).toBe(true);
|
||||
expect(JSON.parse(getContainerConfig(GID)!.additional_mounts)).toEqual([
|
||||
{ hostPath: '/data/.gmail-mcp', containerPath: '/home/node/.gmail-mcp', readonly: true },
|
||||
]);
|
||||
|
||||
// idempotent: a second add does not duplicate
|
||||
await dispatch({ id: 'r2', command: 'groups-config-add-mount', args }, { caller: 'host' });
|
||||
expect(JSON.parse(getContainerConfig(GID)!.additional_mounts)).toHaveLength(1);
|
||||
|
||||
const rm = await dispatch(
|
||||
{
|
||||
id: 'r3',
|
||||
command: 'groups-config-remove-mount',
|
||||
args: { id: GID, host: '/data/.gmail-mcp', container: '/home/node/.gmail-mcp' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
expect(rm.ok).toBe(true);
|
||||
expect(JSON.parse(getContainerConfig(GID)!.additional_mounts)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { McpServerConfig } from '../../container-config.js';
|
||||
import type { AdditionalMountConfig, McpServerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
@@ -399,5 +399,57 @@ registerResource({
|
||||
};
|
||||
},
|
||||
},
|
||||
'config add-mount': {
|
||||
access: 'approval',
|
||||
hostOnly: true,
|
||||
description:
|
||||
"Mount a host directory into a group's containers. OPERATOR-ONLY — never runnable from " +
|
||||
'inside a container (mounting host paths is a filesystem-access boundary). Requires ' +
|
||||
'`ncl groups restart` to take effect. Use --id <group-id> --host <host-path> --container <container-path> [--ro].',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
const hostPath = (args.host ?? args['host-path']) as string | undefined;
|
||||
const containerPath = (args.container ?? args['container-path']) as string | undefined;
|
||||
if (!hostPath || !containerPath) throw new Error('Provide --host <host-path> and --container <container-path>');
|
||||
|
||||
const row = getContainerConfig(id);
|
||||
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||
|
||||
const mount: AdditionalMountConfig = {
|
||||
hostPath,
|
||||
containerPath,
|
||||
...(args.ro || args.readonly ? { readonly: true } : {}),
|
||||
};
|
||||
const existing = JSON.parse(row.additional_mounts) as AdditionalMountConfig[];
|
||||
if (!existing.some((m) => m.hostPath === hostPath && m.containerPath === containerPath)) {
|
||||
existing.push(mount);
|
||||
updateContainerConfigJson(id, 'additional_mounts', existing);
|
||||
}
|
||||
return { added: mount, note: `Run \`ncl groups restart --id ${id}\` for the mount to take effect.` };
|
||||
},
|
||||
},
|
||||
'config remove-mount': {
|
||||
access: 'approval',
|
||||
hostOnly: true,
|
||||
description:
|
||||
'Remove a host mount from a group. OPERATOR-ONLY. Requires `ncl groups restart` to take effect. ' +
|
||||
'Use --id <group-id> --host <host-path> --container <container-path>.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
const hostPath = (args.host ?? args['host-path']) as string | undefined;
|
||||
const containerPath = (args.container ?? args['container-path']) as string | undefined;
|
||||
if (!hostPath || !containerPath) throw new Error('Provide --host <host-path> and --container <container-path>');
|
||||
|
||||
const row = getContainerConfig(id);
|
||||
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||
|
||||
const existing = JSON.parse(row.additional_mounts) as AdditionalMountConfig[];
|
||||
const filtered = existing.filter((m) => !(m.hostPath === hostPath && m.containerPath === containerPath));
|
||||
updateContainerConfigJson(id, 'additional_mounts', filtered);
|
||||
return { removed: { hostPath, containerPath }, note: `Run \`ncl groups restart --id ${id}\` to apply.` };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user