mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
4896887660
One canonical SIEM-shaped event per action, appended to NDJSON day-files under data/audit/. v1 surfaces: every ncl command (both transports, including scope denials) and every host-routed approval — pending, decision, and terminal outcome — covering CLI gates, self-mod, a2a message gates, agent creation (incl. the ungated global-scope door and the channel-registration name flow), the permissions sender/channel cards, and OneCLI credential holds. Emit architecture is wrappers at module edges, zero inline audit calls: withAudit(dispatch) middleware (trace out-param carries the resolved command + effective args), a decorated requestApproval (owns pending events; inner returns the minted hold), an observer on the existing approval-resolved hook (decision events), a wrapped handler run in the response handler (terminal events; cli_command's terminal comes from the replay through the dispatch middleware, correlated via DispatchOptions.approvalId), and audited() seams for permissions, OneCLI, and performCreateAgent. Gated chains share correlation_id = the approval id. Governance guarantees: off by default (the emitter no-ops and data/audit is never created); fail-open + loud on append failure; boot writability assert when enabled (refuse to start over a silent audit gap); recursive secret-key redaction + ~2KB value truncation at the single emit point; message-bearing events record shape only (body_chars, attachment names). Retention (AUDIT_RETENTION_DAYS, default 90, 0 = forever) hard-deletes day-files at boot + once daily in the host sweep. Read back with ncl audit list (--actor --action --resource --outcome --since --until --correlation --limit, --format ndjson) — host + global scope only; group-scoped agents fail closed via the existing allowlist. Design docs (requirements + grill-session decisions) live on the team hub; docs/SECURITY.md carries the operator-facing documentation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
/**
|
|
* Audit middleware behavior of the exported dispatch — what gets recorded,
|
|
* for whom, and when nothing must be recorded. Mirrors dispatch.test.ts
|
|
* mocking; audit is force-enabled and the store's append is captured.
|
|
*/
|
|
import os from 'os';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
|
|
|
vi.mock('../config.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../config.js')>();
|
|
return { ...actual, AUDIT_ENABLED: true };
|
|
});
|
|
|
|
vi.mock('../audit/store.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../audit/store.js')>();
|
|
return {
|
|
...actual,
|
|
appendAuditLine: (line: string) => {
|
|
appended.lines.push(line);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../log.js', () => ({
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
}));
|
|
|
|
const mockGetContainerConfig = vi.fn();
|
|
vi.mock('../db/container-configs.js', () => ({
|
|
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
|
|
}));
|
|
|
|
vi.mock('../db/agent-groups.js', () => ({
|
|
getAgentGroup: vi.fn(() => ({ id: 'g1', name: 'Group One' })),
|
|
}));
|
|
|
|
vi.mock('../db/sessions.js', () => ({
|
|
getSession: vi.fn(() => ({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' })),
|
|
}));
|
|
|
|
vi.mock('../db/messaging-groups.js', () => ({
|
|
getMessagingGroup: vi.fn(() => ({ channel_type: 'slack' })),
|
|
}));
|
|
|
|
const mockGetResource = vi.fn();
|
|
vi.mock('./crud.js', () => ({
|
|
getResource: (...args: unknown[]) => mockGetResource(...args),
|
|
}));
|
|
|
|
vi.mock('../modules/approvals/index.js', () => ({
|
|
registerApprovalHandler: vi.fn(),
|
|
requestApproval: vi.fn(),
|
|
}));
|
|
|
|
import { register } from './registry.js';
|
|
|
|
register({
|
|
name: 'groups-test',
|
|
description: 'echo command on the groups resource',
|
|
resource: 'groups',
|
|
access: 'open',
|
|
parseArgs: (raw) => raw,
|
|
handler: async (args) => ({ echo: args }),
|
|
});
|
|
|
|
register({
|
|
name: 'groups-get',
|
|
description: 'echo command for dash-joined id resolution',
|
|
resource: 'groups',
|
|
access: 'open',
|
|
parseArgs: (raw) => raw,
|
|
handler: async (args) => ({ echo: args }),
|
|
});
|
|
|
|
register({
|
|
name: 'wirings-list',
|
|
description: 'not on the group-scope allowlist',
|
|
resource: 'wirings',
|
|
access: 'open',
|
|
parseArgs: (raw) => raw,
|
|
handler: async () => [],
|
|
});
|
|
|
|
register({
|
|
name: 'groups-fail',
|
|
description: 'handler that throws',
|
|
resource: 'groups',
|
|
access: 'open',
|
|
parseArgs: (raw) => raw,
|
|
handler: async () => {
|
|
throw new Error('boom');
|
|
},
|
|
});
|
|
|
|
register({
|
|
name: 'groups-gated',
|
|
description: 'approval-gated command',
|
|
resource: 'groups',
|
|
access: 'approval',
|
|
parseArgs: (raw) => raw,
|
|
handler: async () => 'ran',
|
|
});
|
|
|
|
import { dispatch } from './dispatch.js';
|
|
import type { CallerContext } from './frame.js';
|
|
|
|
const AGENT_CTX: CallerContext = { caller: 'agent', sessionId: 's1', agentGroupId: 'g1', messagingGroupId: 'mg1' };
|
|
|
|
function events(): Array<Record<string, any>> {
|
|
return appended.lines.map((l) => JSON.parse(l));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
appended.lines.length = 0;
|
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
|
mockGetResource.mockImplementation((plural: string) => (plural === 'groups' ? { scopeField: 'id' } : undefined));
|
|
});
|
|
|
|
describe('withAudit(dispatch)', () => {
|
|
it('records a success event for a host caller with socket origin and host actor', async () => {
|
|
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, { caller: 'host' });
|
|
|
|
expect(resp.ok).toBe(true);
|
|
const [event] = events();
|
|
expect(event).toMatchObject({
|
|
schema_version: 1,
|
|
actor: { type: 'human', id: `host:${os.userInfo().username}`, email: null },
|
|
origin: { transport: 'socket' },
|
|
action: 'groups.test',
|
|
outcome: 'success',
|
|
correlation_id: null,
|
|
details: { foo: 'bar' },
|
|
});
|
|
});
|
|
|
|
it('records effective args after group auto-fill, with container origin and channel', async () => {
|
|
await dispatch({ id: '1', command: 'groups-test', args: {} }, AGENT_CTX);
|
|
|
|
const [event] = events();
|
|
expect(event.actor).toMatchObject({ type: 'agent', id: 'g1' });
|
|
expect(event.origin).toEqual({
|
|
transport: 'container',
|
|
session_id: 's1',
|
|
messaging_group_id: 'mg1',
|
|
channel: 'slack',
|
|
});
|
|
expect(event.details).toMatchObject({ id: 'g1', agent_group_id: 'g1', group: 'g1' });
|
|
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'g1' });
|
|
});
|
|
|
|
it('records a denied event for a scope denial, naming the attempted resource type', async () => {
|
|
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, AGENT_CTX);
|
|
|
|
expect(resp.ok).toBe(false);
|
|
const [event] = events();
|
|
expect(event).toMatchObject({
|
|
action: 'wirings.list',
|
|
outcome: 'denied',
|
|
resources: [{ type: 'wirings' }],
|
|
details: { error: 'forbidden' },
|
|
});
|
|
expect(event.details.reason).toContain('scoped');
|
|
});
|
|
|
|
it('records a failure event when the handler throws', async () => {
|
|
await dispatch({ id: '1', command: 'groups-fail', args: {} }, { caller: 'host' });
|
|
|
|
const [event] = events();
|
|
expect(event).toMatchObject({ action: 'groups.fail', outcome: 'failure', details: { error: 'handler-error' } });
|
|
expect(event.details.reason).toContain('boom');
|
|
});
|
|
|
|
it('records nothing for an approval-pending response — the pending event belongs to requestApproval', async () => {
|
|
const resp = await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX);
|
|
|
|
expect(resp.ok).toBe(false);
|
|
if (!resp.ok) expect(resp.error.code).toBe('approval-pending');
|
|
expect(appended.lines).toHaveLength(0);
|
|
});
|
|
|
|
it('records an approved replay with the approval id as correlation_id', async () => {
|
|
await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX, {
|
|
approved: true,
|
|
approvalId: 'appr-123-abc',
|
|
});
|
|
|
|
const [event] = events();
|
|
expect(event).toMatchObject({ action: 'groups.gated', outcome: 'success', correlation_id: 'appr-123-abc' });
|
|
});
|
|
|
|
it('records unknown commands as cli.unknown-command with the raw name in details', async () => {
|
|
await dispatch({ id: '1', command: 'nope-nothing', args: {} }, { caller: 'host' });
|
|
|
|
const [event] = events();
|
|
expect(event).toMatchObject({
|
|
action: 'cli.unknown-command',
|
|
outcome: 'failure',
|
|
resources: [],
|
|
details: { command: 'nope-nothing', error: 'unknown-command' },
|
|
});
|
|
});
|
|
|
|
it('records the resolved command and id for dash-joined positional ids', async () => {
|
|
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
|
await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
|
|
|
|
const [event] = events();
|
|
expect(event).toMatchObject({ action: 'groups.get', outcome: 'success' });
|
|
expect(event.resources).toContainEqual({ type: 'agent_group', id: uuid });
|
|
expect(event.details.id).toBe(uuid);
|
|
});
|
|
|
|
it('normalizes hyphenated arg keys in details', async () => {
|
|
await dispatch({ id: '1', command: 'groups-test', args: { 'dry-run': 'true' } }, { caller: 'host' });
|
|
|
|
const [event] = events();
|
|
expect(event.details).toMatchObject({ dry_run: 'true' });
|
|
});
|
|
});
|