feat(harness): ncl surface for harness capabilities + dispatch escalation gate

groups config update gains --harness-capabilities 'k=on|off|default[,...]'
(JSON-column read-modify-write, validated against the registry; a
harness-only update passes the nothing-to-update guard). config get
renders raw overrides plus a resolved view with (default)/(override)
markers. dispatch blocks the arg from group-scoped agents exactly like
cli_scope — an agent cannot re-enable capabilities its operator turned
off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-07-08 15:19:01 +03:00
parent 155e685c58
commit 8bdcf20a89
4 changed files with 194 additions and 5 deletions
+29
View File
@@ -316,6 +316,35 @@ describe('CLI scope enforcement', () => {
}
});
it('group: blocks harness_capabilities escalation', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'groups-test', args: { harness_capabilities: 'agent-teams=on' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('harness_capabilities');
}
});
it('group: blocks harness-capabilities escalation (hyphenated)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'groups-test', args: { 'harness-capabilities': 'workflow=on' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('group: blocks non-group resources', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
+7
View File
@@ -91,6 +91,13 @@ export async function dispatch(
return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.');
}
// Same escalation shape for harness capabilities: an agent must not be
// able to re-enable harness features (agent teams, Workflow) that the
// operator turned off for its group.
if (req.args.harness_capabilities !== undefined || req.args['harness-capabilities'] !== undefined) {
return err(req.id, 'forbidden', 'Cannot change harness_capabilities from a group-scoped agent.');
}
// Auto-fill agent-group-related args so the agent doesn't need
// to pass its own group ID explicitly.
const fill: Record<string, unknown> = {
+121
View File
@@ -0,0 +1,121 @@
/**
* ncl round-trip for the harness-capabilities config surface: set an
* override, clear it with `default`, reject unknown keys/values, and render
* both the raw overrides and the resolved (default)/(override) view in
* `config get`. Host caller — the same code path an approved update takes.
*/
import fs from 'fs';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
buildAgentGroupImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-groups-config' };
});
const TEST_DIR = '/tmp/nanoclaw-test-cli-groups-config';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
import { ensureContainerConfig, getContainerConfig } from '../../db/container-configs.js';
import { dispatch } from '../dispatch.js';
// Side-effect import: registers the `groups-*` commands.
import './groups.js';
const GID = 'ag-caps';
const hostCtx = { caller: 'host' as const };
async function configUpdate(caps: string) {
return dispatch(
{ id: 't', command: 'groups-config-update', args: { id: GID, 'harness-capabilities': caps } },
hostCtx,
);
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
runMigrations(initTestDb());
createAgentGroup({
id: GID,
name: 'caps',
folder: 'caps',
agent_provider: null,
created_at: new Date().toISOString(),
});
ensureContainerConfig(GID);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
describe('groups config update --harness-capabilities', () => {
it('sets an override and renders it as (override) in the resolved view', async () => {
const resp = await configUpdate('agent-teams=on');
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as {
harness_capabilities: Record<string, string>;
harness_capabilities_resolved: Record<string, string>;
};
expect(data.harness_capabilities).toEqual({ 'agent-teams': 'on' });
expect(data.harness_capabilities_resolved['agent-teams']).toBe('on (override)');
expect(data.harness_capabilities_resolved.workflow).toBe('off (default)');
}
expect(JSON.parse(getContainerConfig(GID)!.harness_capabilities)).toEqual({ 'agent-teams': 'on' });
});
it('`default` clears the override and the resolved view returns to (default)', async () => {
await configUpdate('agent-teams=on');
const resp = await configUpdate('agent-teams=default');
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { harness_capabilities_resolved: Record<string, string> };
expect(data.harness_capabilities_resolved['agent-teams']).toBe('off (default)');
}
expect(JSON.parse(getContainerConfig(GID)!.harness_capabilities)).toEqual({});
});
it('rejects unknown keys and bad values with usable messages', async () => {
const unknown = await configUpdate('web=off');
expect(unknown.ok).toBe(false);
if (!unknown.ok) expect(unknown.error.message).toContain('unknown harness capability');
const badValue = await configUpdate('workflow=sideways');
expect(badValue.ok).toBe(false);
if (!badValue.ok) expect(badValue.error.message).toContain('on, off, or default');
});
it('a harness-only update passes the nothing-to-update guard', async () => {
const resp = await configUpdate('workflow=on');
expect(resp.ok).toBe(true);
});
it('config get shows raw overrides plus the resolved view', async () => {
await configUpdate('workflow=on');
const resp = await dispatch({ id: 't', command: 'groups-config-get', args: { id: GID } }, hostCtx);
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as {
harness_capabilities: Record<string, string>;
harness_capabilities_resolved: Record<string, string>;
};
expect(data.harness_capabilities).toEqual({ workflow: 'on' });
expect(data.harness_capabilities_resolved).toEqual({
'agent-teams': 'off (default)',
workflow: 'on (override)',
});
}
});
});
+37 -5
View File
@@ -13,11 +13,25 @@ import {
updateContainerConfigJson,
} from '../../db/container-configs.js';
import { createAgentFromTemplate } from '../../templates/create-agent.js';
import { parseHarnessCapabilitiesArg, resolveHarnessCapabilities } from '../../harness-capabilities.js';
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
import { registerResource } from '../crud.js';
/** Raw override map from the stored JSON; malformed degrades to {} (resolve warns). */
function harnessOverrides(row: ContainerConfigRow): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(row.harness_capabilities);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
return {};
}
}
/** Deserialize JSON columns for display. */
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
const overrides = harnessOverrides(row);
const resolved = resolveHarnessCapabilities(row.harness_capabilities);
return {
agent_group_id: row.agent_group_id,
provider: row.provider,
@@ -32,6 +46,10 @@ function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
packages_npm: JSON.parse(row.packages_npm),
additional_mounts: JSON.parse(row.additional_mounts),
cli_scope: row.cli_scope,
harness_capabilities: overrides,
harness_capabilities_resolved: Object.fromEntries(
Object.entries(resolved).map(([k, v]) => [k, `${v} (${k in overrides ? 'override' : 'default'})`]),
),
updated_at: row.updated_at,
};
}
@@ -242,8 +260,9 @@ registerResource({
'config update': {
access: 'approval',
description:
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
'Update container config fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope, ' +
"--harness-capabilities 'key=on|off|default[,key=...]' (keys: agent-teams, workflow; `default` clears the per-group override).",
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
@@ -271,13 +290,26 @@ registerResource({
updates.cli_scope = scope;
}
if (Object.keys(updates).length === 0) {
// Harness capabilities are a JSON column of sparse overrides — apply
// set/clear ops on the stored map (`default` clears; never stored).
let harnessChanged = false;
const capsArg = (args['harness-capabilities'] ?? args.harness_capabilities) as string | undefined;
if (capsArg !== undefined) {
const ops = parseHarnessCapabilitiesArg(String(capsArg));
const overrides = harnessOverrides(row);
for (const key of ops.clear) delete overrides[key];
Object.assign(overrides, ops.set);
updateContainerConfigJson(id, 'harness_capabilities', overrides);
harnessChanged = true;
}
if (Object.keys(updates).length === 0 && !harnessChanged) {
throw new Error(
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope, --harness-capabilities',
);
}
updateContainerConfigScalars(id, updates);
if (Object.keys(updates).length > 0) updateContainerConfigScalars(id, updates);
const updated = getContainerConfig(id)!;
return presentConfig(updated);