Compare commits

...

1 Commits

Author SHA1 Message Date
gavrielc db59ddbe9c feat(cli): host-only ncl groups config add-mount / remove-mount
Mounting a host directory into a group's containers is a filesystem-access
boundary, so this is an OPERATOR-ONLY verb. A new `hostOnly` flag on commands,
enforced in dispatch BEFORE scope/approval, rejects any container (agent) caller
regardless of cli_scope — even `global`, even with approval — because the mount
allowlist is the boundary cli_scope itself lives inside.

Mirrors add-package: writes additional_mounts, idempotent, paired remove. It's
the WHO layer, complementing the existing spawn-time mount allowlist (the WHAT,
stored outside the project root). Tests: host-only rejection at global scope +
add/idempotent/remove behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:49 +03:00
6 changed files with 140 additions and 1 deletions
+3
View File
@@ -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),
+28
View File
@@ -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' });
+8
View File
@@ -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);
+7
View File
@@ -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;
/**
+41
View File
@@ -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([]);
});
});
+53 -1
View File
@@ -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.` };
},
},
},
});