From 4896887660ec84e025b161c7e8dcd6eca6a60668 Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Mon, 6 Jul 2026 10:20:13 +0300 Subject: [PATCH] feat: opt-in local audit log (AUDIT_ENABLED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + CLAUDE.md | 2 + docs/SECURITY.md | 39 ++ src/audit/emit.ts | 43 +++ src/audit/index.ts | 12 + src/audit/init.ts | 27 ++ src/audit/mapping.ts | 285 ++++++++++++++ src/audit/observer.ts | 48 +++ src/audit/reader.test.ts | 150 +++++++ src/audit/reader.ts | 160 ++++++++ src/audit/redact.test.ts | 69 ++++ src/audit/redact.ts | 39 ++ src/audit/store.test.ts | 183 +++++++++ src/audit/store.ts | 96 +++++ src/audit/types.ts | 64 +++ src/audit/wrappers.test.ts | 287 ++++++++++++++ src/audit/wrappers.ts | 365 ++++++++++++++++++ src/cli/crud.ts | 6 + src/cli/dispatch.audit.test.ts | 222 +++++++++++ src/cli/dispatch.ts | 46 ++- src/cli/registry.ts | 15 +- src/cli/resources/audit.ts | 50 +++ src/cli/resources/index.ts | 1 + src/config.ts | 10 + src/host-sweep.ts | 9 + src/index.ts | 5 + src/modules/agent-to-agent/create-agent.ts | 22 +- .../agent-to-agent/message-gate.test.ts | 1 + src/modules/approvals/onecli-approvals.ts | 29 +- src/modules/approvals/primitive.audit.test.ts | 164 ++++++++ src/modules/approvals/primitive.ts | 32 +- .../approvals/response-handler.audit.test.ts | 202 ++++++++++ src/modules/approvals/response-handler.ts | 12 +- src/modules/permissions/index.ts | 100 +++-- 34 files changed, 2741 insertions(+), 55 deletions(-) create mode 100644 src/audit/emit.ts create mode 100644 src/audit/index.ts create mode 100644 src/audit/init.ts create mode 100644 src/audit/mapping.ts create mode 100644 src/audit/observer.ts create mode 100644 src/audit/reader.test.ts create mode 100644 src/audit/reader.ts create mode 100644 src/audit/redact.test.ts create mode 100644 src/audit/redact.ts create mode 100644 src/audit/store.test.ts create mode 100644 src/audit/store.ts create mode 100644 src/audit/types.ts create mode 100644 src/audit/wrappers.test.ts create mode 100644 src/audit/wrappers.ts create mode 100644 src/cli/dispatch.audit.test.ts create mode 100644 src/cli/resources/audit.ts create mode 100644 src/modules/approvals/primitive.audit.test.ts create mode 100644 src/modules/approvals/response-handler.audit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 609dc7de0..14a0a6744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file. ## [Unreleased] +- **Opt-in local audit log.** `AUDIT_ENABLED=true` records every `ncl` command (both transports, including scope denials) and every host-routed approval — request, 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 — as one canonical SIEM-shaped event per action, appended to NDJSON day-files under `data/audit/`. Gated chains share a `correlation_id` (the approval id); details pass a recursive secret-key redactor and message-bearing events record shape only (never bodies). `AUDIT_RETENTION_DAYS` (default 90; `0` = keep forever) hard-deletes day-files past the horizon 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 callers only. Off by default: nothing is persisted until enabled, and an enabled box refuses to boot if `data/audit/` isn't writable. See [docs/SECURITY.md](docs/SECURITY.md#6-local-audit-log-opt-in). - **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host. - [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-` skill to pull the matching `4.29.0` adapter. - **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop. diff --git a/CLAUDE.md b/CLAUDE.md index 75642412b..72b7b831b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | | `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge | | `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache | +| `src/audit/` | Opt-in local audit log (`AUDIT_ENABLED=true`): single emit seam + wrappers composed at module edges (dispatch middleware, `requestApproval` decorator, approval-resolved observer, permissions/OneCLI/create-agent seams), append-only NDJSON day-files under `data/audit/`, retention prune, `ncl audit` reader. See the "Local Audit Log" section in [docs/SECURITY.md](docs/SECURITY.md) | | `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group | | `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) | | `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup | @@ -108,6 +109,7 @@ ncl help | user-dms | list | Cold-DM cache (read-only) | | dropped-messages | list | Messages from unregistered senders (read-only) | | approvals | list, get | Pending approval requests (read-only) | +| audit | list | Local audit log (read-only; host + global scope only; requires `AUDIT_ENABLED=true`; `--format ndjson` for export) | Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 19e645e07..c4c9ee46b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -169,6 +169,45 @@ pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies on a **non-proxy-aware** tool reaching the internet directly will fail by design. Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`. +### 6. Local Audit Log (Opt-In) + +Every `ncl` command (both transports — host socket and container — including +denials) and every approval the host routes (request, decision, and terminal +outcome; covering CLI gates, self-mod, a2a message gates, agent creation, the +permissions sender/channel cards, and OneCLI credential holds) is recorded as +one canonical, SIEM-shaped JSON event. + +- **Off by default.** Nothing is persisted until an operator sets + `AUDIT_ENABLED=true`; when disabled, the emitter no-ops and `data/audit/` is + never created. `ncl audit list` on a disabled box errors instead of returning + an empty list. +- **Store:** append-only NDJSON day-files under `data/audit/.ndjson`, + written only by the host process. Retention is a hard delete — whole + day-files past the horizon are unlinked at boot and once daily in the host + sweep. +- **Fail-open + loud:** a failed append is logged and the action proceeds (a + full disk must not brick recovery commands). At boot, an enabled box refuses + to start if `data/audit/` isn't writable. +- **No secrets, no message bodies:** a recursive key mask + (`token|secret|key|password|credential|auth|bearer`) redacts details at the + single emit point, values are truncated to ~2 KB, and message-bearing events + (a2a gates, OneCLI body previews) record shape only — `body_chars` and + attachment names, never content. +- **Scope:** `ncl audit list` is available to host callers and global-scope + agents only. `audit` is not on the group-scope allowlist, so confined agents + are refused before any handler runs. + +| Env | Default | Meaning | +| --- | --- | --- | +| `AUDIT_ENABLED` | `false` | Set `true` to record audit events. | +| `AUDIT_RETENTION_DAYS` | `90` | Days before day-files are unlinked; `0` = keep forever. Read only when enabled. | + +Read back with `ncl audit list --actor … --action … --resource … --outcome … +--since 7d --correlation … --limit 100`; `--format ndjson` streams the stored +lines for SIEM export. Event fields are chosen to project losslessly onto +OCSF and Elastic ECS; forwarding is a mapping exercise, deferred until a +forwarder exists. + ## Resource Limits Per-container CPU and memory caps are **opt-in and unset by default** — a runaway diff --git a/src/audit/emit.ts b/src/audit/emit.ts new file mode 100644 index 000000000..d24f52941 --- /dev/null +++ b/src/audit/emit.ts @@ -0,0 +1,43 @@ +/** + * emitAuditEvent — the single opt-in check and the single append point. + * + * Only src/audit/ may call this (wrappers compose it at module edges; + * business logic never does): `grep emitAuditEvent src/` outside this + * directory must stay empty. + */ +import { randomUUID } from 'crypto'; + +import { AUDIT_ENABLED } from '../config.js'; +import { log } from '../log.js'; +import { redactDetails } from './redact.js'; +import { appendAuditLine } from './store.js'; +import type { AuditEvent, AuditEventInput } from './types.js'; + +export function emitAuditEvent(input: AuditEventInput | (() => AuditEventInput)): void { + if (!AUDIT_ENABLED) return; // The one opt-in check — the whole feature switches here. + try { + // Lazy inputs keep a disabled box at literally zero audit work (and shield + // the action from assembly errors — origin lookups can touch the DB). + if (typeof input === 'function') input = input(); + const event: AuditEvent = { + event_id: randomUUID(), + time: new Date().toISOString(), + schema_version: 1, + // Directory-enrichment fields stamp null until the adapter lands. + actor: { ...input.actor, email: null, user_id: null, group_ids: null }, + origin: input.origin, + action: input.action, + resources: input.resources, + outcome: input.outcome, + correlation_id: input.correlationId ?? null, + details: redactDetails(input.details ?? {}), + }; + appendAuditLine(JSON.stringify(event)); + // eslint-disable-next-line no-catch-all/no-catch-all -- fail-open is the contract: auditing must never take down the audited action + } catch (err) { + // Fail-open + loud: the audited action must proceed even when the log + // can't be written (a full disk must not brick recovery commands). + const action = typeof input === 'function' ? undefined : input.action; + log.error('Audit append failed — action proceeding (fail-open)', { action, err }); + } +} diff --git a/src/audit/index.ts b/src/audit/index.ts new file mode 100644 index 000000000..dfd1c82a7 --- /dev/null +++ b/src/audit/index.ts @@ -0,0 +1,12 @@ +/** + * Opt-in local audit log (docs/SECURITY.md, "Local Audit Log"). + * + * Everything composes through the wrappers exported here — business logic + * contains zero audit calls. emitAuditEvent is deliberately NOT re-exported: + * only src/audit/ internals may call it. + */ +export * from './types.js'; +export { redactDetails } from './redact.js'; +export { AUDIT_DIR, pruneAuditLogIfDue } from './store.js'; +export { initAuditLog } from './init.js'; +export { runApprovedHandler, withAudit } from './wrappers.js'; diff --git a/src/audit/init.ts b/src/audit/init.ts new file mode 100644 index 000000000..619861a07 --- /dev/null +++ b/src/audit/init.ts @@ -0,0 +1,27 @@ +/** + * Boot-time audit wiring — one composition line in src/index.ts. + * + * The decision observer registers unconditionally (its emits no-op when + * disabled). When enabled: assert data/audit/ is writable (refusing to start + * beats running with a silent audit gap) and run the boot prune. + */ +import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from '../config.js'; +import { log } from '../log.js'; +import { registerAuditObserver } from './observer.js'; +import { assertAuditWritable, AUDIT_DIR, markPrunedToday, pruneAuditLog } from './store.js'; + +export function initAuditLog(): void { + registerAuditObserver(); + if (!AUDIT_ENABLED) return; + try { + assertAuditWritable(); + } catch (err) { + throw new Error( + `AUDIT_ENABLED=true but the audit directory is not writable: ${AUDIT_DIR} (${err instanceof Error ? err.message : String(err)})`, + { cause: err }, + ); + } + pruneAuditLog(); + markPrunedToday(); + log.info('Audit log enabled', { dir: AUDIT_DIR, retentionDays: AUDIT_RETENTION_DAYS }); +} diff --git a/src/audit/mapping.ts b/src/audit/mapping.ts new file mode 100644 index 000000000..e67f2c8a0 --- /dev/null +++ b/src/audit/mapping.ts @@ -0,0 +1,285 @@ +/** + * Event assembly — actors, origins, action names, resources, and the + * per-surface details rules (including shape-only for message-bearing + * payloads). Pure derivation; nothing here writes the log. + */ +import os from 'os'; + +import { getResource } from '../cli/crud.js'; +import type { CallerContext, RequestFrame } from '../cli/frame.js'; +import { type CommandDef, lookup } from '../cli/registry.js'; +import { getMessagingGroup } from '../db/messaging-groups.js'; +import type { Session } from '../types.js'; +import { type AuditActor, type AuditEventInput, type AuditOrigin, type AuditResource, SYSTEM_ACTOR } from './types.js'; + +// ── Actors ── + +function hostUser(): string { + try { + return os.userInfo().username; + // eslint-disable-next-line no-catch-all/no-catch-all -- os.userInfo throws on exotic hosts; a fallback actor id beats no audit event + } catch { + return process.env.USER || 'unknown'; + } +} + +/** + * Host callers stamp `host:` daemon-side: the ncl socket is + * 0600 and owned by the install user, so the identity is accurate by + * construction without peer credentials. + */ +export function actorForCaller(ctx: CallerContext): AuditActor { + return ctx.caller === 'host' ? { type: 'human', id: `host:${hostUser()}` } : { type: 'agent', id: ctx.agentGroupId }; +} + +/** Empty resolver id (sweep/timer paths) → the system actor. */ +export function humanOrSystemActor(namespacedUserId: string): AuditActor { + return namespacedUserId ? { type: 'human', id: namespacedUserId } : SYSTEM_ACTOR; +} + +// ── Origins ── + +export function originForCaller(ctx: CallerContext): AuditOrigin { + if (ctx.caller === 'host') return { transport: 'socket' }; + return containerOrigin(ctx.sessionId, ctx.messagingGroupId || null); +} + +export function originForSession(session: Session): AuditOrigin { + return containerOrigin(session.id, session.messaging_group_id); +} + +function containerOrigin(sessionId: string, messagingGroupId: string | null): AuditOrigin { + const origin: AuditOrigin = { transport: 'container', session_id: sessionId }; + if (messagingGroupId) { + origin.messaging_group_id = messagingGroupId; + const channel = getMessagingGroup(messagingGroupId)?.channel_type; + if (channel) origin.channel = channel; + } + return origin; +} + +/** Approval decisions arrive as card clicks on a chat platform. */ +export function channelOriginForUser(namespacedUserId: string): AuditOrigin { + const idx = namespacedUserId.indexOf(':'); + const channel = idx > 0 ? namespacedUserId.slice(0, idx) : undefined; + return channel ? { transport: 'channel', channel } : { transport: 'channel' }; +} + +// ── CLI resources ── + +/** + * Frame-level args use `--hyphen-keys`; recorded details use the same + * underscore form the parsed handlers see. Mirrors crud's normalizeArgs + * (kept local so audit doesn't depend on a module tests commonly mock). + */ +export function normalizeArgKeys(raw: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + out[k.replace(/-/g, '_')] = v; + } + return out; +} + +/** CLI resource plural → audit resource type, where the singular isn't it. */ +const RESOURCE_TYPE_OVERRIDES: Record = { + groups: 'agent_group', + 'messaging-groups': 'messaging_group', + 'dropped-messages': 'dropped_message', + 'user-dms': 'user_dm', +}; + +/** + * Derive touched/attempted resources from a command's effective args. Generic + * by design: `id` → the command's own resource, group/user args → their + * types, and a bare `{type}` entry when nothing else is known (a denied + * `users list` still names what was attempted). + */ +export function resourcesForCli(cmd: CommandDef, args: Record): AuditResource[] { + if (!cmd.resource) return []; + const type = RESOURCE_TYPE_OVERRIDES[cmd.resource] ?? getResource(cmd.resource)?.name ?? cmd.resource; + + const out: AuditResource[] = []; + const push = (t: string, id: unknown): void => { + if (typeof id !== 'string' || !id) return; + if (!out.some((r) => r.type === t && r.id === id)) out.push({ type: t, id }); + }; + push(type, args.id); + push('agent_group', args.agent_group_id ?? args.group); + push('user', args.user); + if (out.length === 0) out.push({ type }); + return out; +} + +// ── Approval-gated actions ── + +/** Dotted audit action per approval type; cli_command derives from its frame. */ +export const APPROVAL_AUDIT_ACTIONS: Record = { + install_packages: 'self-mod.install-packages', + add_mcp_server: 'self-mod.add-mcp-server', + create_agent: 'agents.create', + a2a_message_gate: 'messages.a2a-gate', + onecli_credential: 'onecli.credential.use', +}; + +export function auditActionForApproval(approvalAction: string, payload: Record): string { + if (approvalAction === 'cli_command') { + const frame = payload.frame as RequestFrame | undefined; + return (frame && lookup(frame.command)?.action) ?? 'cli.unknown-command'; + } + return APPROVAL_AUDIT_ACTIONS[approvalAction] ?? approvalAction.replace(/_/g, '.'); +} + +/** + * details for an approval's pending/terminal events. Message-bearing payloads + * (a2a gate) record shape only — body_chars and attachment names, never + * content; the emit-seam redactor is defense-in-depth, not the mechanism. + */ +export function detailsForApprovalPayload( + approvalAction: string, + payload: Record, +): Record { + switch (approvalAction) { + case 'cli_command': { + const frame = payload.frame as RequestFrame | undefined; + return { ...(frame?.args ?? {}) }; + } + case 'create_agent': { + const instructions = typeof payload.instructions === 'string' ? payload.instructions : null; + return { name: payload.name, instructions_chars: instructions ? instructions.length : 0 }; + } + case 'a2a_message_gate': { + const { text, files } = messageShape(payload.content); + return { to: payload.platform_id, body_chars: text.length, attachments: files }; + } + default: + // install_packages, add_mcp_server, and future types: metadata payloads + // pass through as-is — the emit-seam redactor masks sensitive keys. + return { ...payload }; + } +} + +export function resourcesForApproval( + approvalAction: string, + payload: Record, + session: Session, +): AuditResource[] { + const base: AuditResource[] = [{ type: 'agent_group', id: session.agent_group_id }]; + if (approvalAction === 'cli_command') { + const frame = payload.frame as RequestFrame | undefined; + const cmd = frame && lookup(frame.command); + if (cmd && frame) { + const cliResources = resourcesForCli(cmd, frame.args); + for (const r of cliResources) { + if (!base.some((b) => b.type === r.type && b.id === r.id)) base.push(r); + } + } + } + if (approvalAction === 'a2a_message_gate' && typeof payload.platform_id === 'string' && payload.platform_id) { + if (payload.platform_id !== session.agent_group_id) { + base.push({ type: 'agent_group', id: payload.platform_id }); + } + } + return base; +} + +// ── OneCLI credential approvals ── + +/** = APPROVAL_AUDIT_ACTIONS.onecli_credential; named for the OneCLI wrappers. */ +export const ONECLI_AUDIT_ACTION = 'onecli.credential.use'; + +interface OneCliRowShape { + approval_id: string; + agent_group_id?: string | null; + payload: string; +} + +interface OneCliPayload { + oneCliRequestId?: string; + method?: string; + host?: string; + path?: string; + bodyPreview?: string; + agent?: { externalId?: string | null; name?: string }; + approver?: string; +} + +function oneCliPayload(row: OneCliRowShape): OneCliPayload { + try { + const parsed: unknown = JSON.parse(row.payload); + return parsed && typeof parsed === 'object' ? (parsed as OneCliPayload) : {}; + // eslint-disable-next-line no-catch-all/no-catch-all -- stored payloads are untrusted; a malformed one must not break event assembly + } catch { + return {}; + } +} + +/** Shape-only: the request body's presence is auditable, its content never is. */ +function oneCliDetails(p: OneCliPayload): Record { + return { + method: p.method, + host: p.host, + path: p.path, + one_cli_request_id: p.oneCliRequestId, + body_preview_chars: typeof p.bodyPreview === 'string' ? p.bodyPreview.length : 0, + }; +} + +function oneCliResources(row: OneCliRowShape): AuditResource[] { + const out: AuditResource[] = []; + if (row.agent_group_id) out.push({ type: 'agent_group', id: row.agent_group_id }); + out.push({ type: 'approval', id: row.approval_id }); + return out; +} + +/** Pending event for a OneCLI credential hold, derived from its row. */ +export function oneCliHoldEvent(row: OneCliRowShape): AuditEventInput { + const p = oneCliPayload(row); + const resources = oneCliResources(row); + if (p.approver) resources.push({ type: 'user', id: p.approver }); + return { + actor: { type: 'agent', id: row.agent_group_id ?? p.agent?.externalId ?? p.agent?.name ?? 'unknown' }, + // OneCLI requests come from inside an agent container but carry no session. + origin: { transport: 'container' }, + action: ONECLI_AUDIT_ACTION, + resources, + outcome: 'pending', + correlationId: row.approval_id, + details: oneCliDetails(p), + }; +} + +/** approvals.decide for a OneCLI resolution (click, expiry, or startup sweep). */ +export function oneCliDecideEvent( + row: OneCliRowShape & { channel_type?: string | null }, + args: { actor: AuditActor; origin: AuditOrigin; outcome: 'approved' | 'rejected'; reason?: string }, +): AuditEventInput { + const details: Record = { + gated_action: ONECLI_AUDIT_ACTION, + ...oneCliDetails(oneCliPayload(row)), + }; + if (args.reason) details.reason = args.reason; + return { + actor: args.actor, + origin: args.origin, + action: 'approvals.decide', + resources: oneCliResources(row), + outcome: args.outcome, + correlationId: row.approval_id, + details, + }; +} + +/** Mirror of agent-route's message-content parse — shape extraction only. */ +function messageShape(content: unknown): { text: string; files: string[] } { + if (typeof content !== 'string') return { text: '', files: [] }; + try { + const parsed = JSON.parse(content) as { text?: unknown; files?: unknown }; + return { + text: typeof parsed.text === 'string' ? parsed.text : '', + files: Array.isArray(parsed.files) ? parsed.files.filter((f): f is string => typeof f === 'string') : [], + }; + // eslint-disable-next-line no-catch-all/no-catch-all -- non-JSON content is the raw body; shape extraction must not throw + } catch { + return { text: content, files: [] }; + } +} diff --git a/src/audit/observer.ts b/src/audit/observer.ts new file mode 100644 index 000000000..4fc472516 --- /dev/null +++ b/src/audit/observer.ts @@ -0,0 +1,48 @@ +/** + * Decision events — approvals.decide, one per resolved approval, riding the + * existing approval-resolved hook. Covers every requestApproval-backed action + * (cli_command, self-mod, create_agent, a2a gate); OneCLI never reaches + * notifyApprovalResolved and emits its decisions from its own wrappers. + */ +// Direct file import (not the approvals barrel) to keep the graph tight. +import { type ApprovalResolvedEvent, registerApprovalResolvedHandler } from '../modules/approvals/primitive.js'; +import { emitAuditEvent } from './emit.js'; +import { auditActionForApproval, channelOriginForUser, humanOrSystemActor, resourcesForApproval } from './mapping.js'; + +export function onApprovalResolved(event: ApprovalResolvedEvent): void { + emitAuditEvent(() => { + const payload = safeParse(event.approval.payload); + return { + // '' resolver = sweep/timer (e.g. the awaiting-reason ghost sweep) → system. + actor: humanOrSystemActor(event.userId), + // Decisions are card clicks on a chat platform, even system-finalized + // ones — the card lifecycle is the surface. + origin: channelOriginForUser(event.userId), + action: 'approvals.decide', + resources: [ + ...resourcesForApproval(event.approval.action, payload, event.session), + { type: 'approval', id: event.approval.approval_id }, + ], + outcome: event.outcome === 'approve' ? 'approved' : 'rejected', + correlationId: event.approval.approval_id, + details: { + gated_action: auditActionForApproval(event.approval.action, payload), + requested_by: event.session.agent_group_id, + }, + }; + }); +} + +export function registerAuditObserver(): void { + registerApprovalResolvedHandler(onApprovalResolved); +} + +function safeParse(json: string): Record { + try { + const parsed: unknown = JSON.parse(json); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + // eslint-disable-next-line no-catch-all/no-catch-all -- stored payloads are untrusted; a malformed one must not break the decision event + } catch { + return {}; + } +} diff --git a/src/audit/reader.test.ts b/src/audit/reader.test.ts new file mode 100644 index 000000000..b889434e9 --- /dev/null +++ b/src/audit/reader.test.ts @@ -0,0 +1,150 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ dataDir: '', enabled: true })); + +vi.mock('../config.js', () => ({ + get DATA_DIR() { + return state.dataDir; + }, + get AUDIT_ENABLED() { + return state.enabled; + }, + AUDIT_RETENTION_DAYS: 90, +})); + +vi.mock('../log.js', () => ({ + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, +})); + +let reader: typeof import('./reader.js'); +let store: typeof import('./store.js'); + +beforeEach(async () => { + state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-reader-')); + state.enabled = true; + vi.resetModules(); + store = await import('./store.js'); + reader = await import('./reader.js'); +}); + +afterEach(() => { + fs.rmSync(state.dataDir, { recursive: true, force: true }); +}); + +function dayString(daysAgo: number): string { + return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)); +} + +function isoAt(daysAgo: number, tag: number): string { + const base = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + return `${base.toISOString().slice(0, 10)}T10:00:0${tag}.000Z`; +} + +let seq = 0; +function seedEvent(daysAgo: number, overrides: Record = {}): Record { + seq += 1; + const event = { + event_id: `e-${seq}`, + time: isoAt(daysAgo, seq % 10), + schema_version: 1, + actor: { type: 'human', id: 'host:moshe', email: null, user_id: null, group_ids: null }, + origin: { transport: 'socket' }, + action: 'groups.list', + resources: [{ type: 'agent_group', id: 'ag-1' }], + outcome: 'success', + correlation_id: null, + details: {}, + ...overrides, + }; + const dir = path.join(state.dataDir, 'audit'); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(path.join(dir, `${dayString(daysAgo)}.ndjson`), JSON.stringify(event) + '\n'); + return event; +} + +describe('listAuditEvents', () => { + it('throws the disabled error rather than returning an empty list', () => { + state.enabled = false; + expect(() => reader.listAuditEvents({})).toThrow('audit log is disabled — set AUDIT_ENABLED=true'); + }); + + it('returns flat rows newest-first across day-files, honoring --limit', () => { + seedEvent(2, { event_id: 'old' }); + seedEvent(1, { event_id: 'mid-a' }); + seedEvent(1, { event_id: 'mid-b' }); + seedEvent(0, { event_id: 'new' }); + + const rows = reader.listAuditEvents({}) as Array>; + expect(rows.map((r) => r.event_id)).toEqual(['new', 'mid-b', 'mid-a', 'old']); + expect(rows[0]).toMatchObject({ actor: 'host:moshe', action: 'groups.list', outcome: 'success' }); + expect(rows[0].resources).toBe('agent_group:ag-1'); + + const limited = reader.listAuditEvents({ limit: 2 }) as Array>; + expect(limited.map((r) => r.event_id)).toEqual(['new', 'mid-b']); + }); + + it('filters by actor, outcome, correlation, and resource (id or type)', () => { + seedEvent(0, { event_id: 'a', actor: { type: 'agent', id: 'ag-1' }, outcome: 'denied' }); + seedEvent(0, { event_id: 'b', correlation_id: 'appr-9', resources: [{ type: 'approval', id: 'appr-9' }] }); + seedEvent(0, { event_id: 'c', resources: [{ type: 'user', id: 'slack:U1' }] }); + + expect((reader.listAuditEvents({ actor: 'ag-1' }) as unknown[]).length).toBe(1); + expect((reader.listAuditEvents({ outcome: 'denied' }) as unknown[]).length).toBe(1); + expect((reader.listAuditEvents({ correlation: 'appr-9' }) as unknown[]).length).toBe(1); + expect((reader.listAuditEvents({ resource: 'slack:U1' }) as unknown[]).length).toBe(1); + expect((reader.listAuditEvents({ resource: 'approval' }) as unknown[]).length).toBe(1); + }); + + it('matches actions exactly or by dotted prefix', () => { + seedEvent(0, { event_id: 'cfg', action: 'groups.config.add-mcp-server' }); + seedEvent(0, { event_id: 'list', action: 'groups.list' }); + seedEvent(0, { event_id: 'other', action: 'sessions.list' }); + + expect((reader.listAuditEvents({ action: 'groups' }) as unknown[]).length).toBe(2); + expect((reader.listAuditEvents({ action: 'groups.config' }) as unknown[]).length).toBe(1); + expect((reader.listAuditEvents({ action: 'groups.list' }) as unknown[]).length).toBe(1); + // Prefix means dotted segments, not substrings. + expect((reader.listAuditEvents({ action: 'group' }) as unknown[]).length).toBe(0); + }); + + it('applies --since/--until with relative and ISO forms', () => { + seedEvent(5, { event_id: 'old' }); + seedEvent(0, { event_id: 'recent' }); + + const relative = reader.listAuditEvents({ since: '2d' }) as Array>; + expect(relative.map((r) => r.event_id)).toEqual(['recent']); + + const iso = reader.listAuditEvents({ until: dayString(2) }) as Array>; + expect(iso.map((r) => r.event_id)).toEqual(['old']); + + expect(() => reader.listAuditEvents({ since: 'yesterdayish' })).toThrow('invalid --since'); + }); + + it('--format ndjson returns the stored lines verbatim', () => { + const seeded = seedEvent(0, { event_id: 'x1' }); + const out = reader.listAuditEvents({ format: 'ndjson' }); + expect(typeof out).toBe('string'); + expect(JSON.parse(out as string)).toEqual(seeded); + expect(() => reader.listAuditEvents({ format: 'csv' })).toThrow('invalid --format'); + }); + + it('skips malformed stored lines and still returns the rest', () => { + seedEvent(0, { event_id: 'good' }); + fs.appendFileSync(path.join(state.dataDir, 'audit', `${dayString(0)}.ndjson`), 'not-json\n'); + + const rows = reader.listAuditEvents({}) as Array>; + expect(rows.map((r) => r.event_id)).toEqual(['good']); + }); + + it('rejects an unknown --outcome value', () => { + expect(() => reader.listAuditEvents({ outcome: 'meh' })).toThrow('invalid --outcome'); + }); + + it('returns empty when nothing has been recorded yet', () => { + expect(reader.listAuditEvents({})).toEqual([]); + }); +}); diff --git a/src/audit/reader.ts b/src/audit/reader.ts new file mode 100644 index 000000000..915476744 --- /dev/null +++ b/src/audit/reader.ts @@ -0,0 +1,160 @@ +/** + * Read-back for `ncl audit list` — a newest-first stream-scan over the + * day-files. No index: fine at v1 volume, and adding one later doesn't change + * the store. NDJSON export returns the stored lines verbatim. + */ +import fs from 'fs'; +import path from 'path'; + +import { AUDIT_ENABLED } from '../config.js'; +import { log } from '../log.js'; +import { AUDIT_DIR, utcDay } from './store.js'; +import type { AuditEvent, AuditOutcome } from './types.js'; + +const OUTCOMES: ReadonlySet = new Set(['success', 'failure', 'denied', 'pending', 'approved', 'rejected']); +const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/; +const DEFAULT_LIMIT = 100; + +export interface AuditQuery { + actor?: string; + /** Exact action or dotted prefix (`groups` matches `groups.config.update`). */ + action?: string; + /** Matches any resources[] entry by id or by type. */ + resource?: string; + outcome?: AuditOutcome; + sinceMs?: number; + untilMs?: number; + correlation?: string; + limit: number; +} + +/** `7d` / `24h` / `30m` relative to now, or an ISO date/datetime (UTC). */ +export function parseTimeFlag(value: string, flag: string): number { + const rel = /^(\d+)([dhm])$/.exec(value); + if (rel) { + const n = Number(rel[1]); + const unitMs = rel[2] === 'd' ? 86_400_000 : rel[2] === 'h' ? 3_600_000 : 60_000; + return Date.now() - n * unitMs; + } + const abs = Date.parse(value); + if (!Number.isNaN(abs)) return abs; + throw new Error(`invalid ${flag} value "${value}" — use e.g. 7d, 24h, 30m, or an ISO date`); +} + +/** Newest first across files and within each file, up to q.limit. */ +export function queryAuditEvents(q: AuditQuery): { events: AuditEvent[]; lines: string[] } { + const events: AuditEvent[] = []; + const lines: string[] = []; + let malformed = 0; + + for (const { day, file } of dayFilesNewestFirst()) { + if (events.length >= q.limit) break; + // Whole-day skip: a file can't match a window its day lies outside. + if (q.sinceMs !== undefined && day < utcDay(new Date(q.sinceMs))) continue; + if (q.untilMs !== undefined && day > utcDay(new Date(q.untilMs))) continue; + + let content: string; + try { + content = fs.readFileSync(file, 'utf8'); + // eslint-disable-next-line no-catch-all/no-catch-all -- a torn/pruned day-file must not fail the whole query + } catch (err) { + log.warn('Audit reader failed to read day-file', { file, err }); + continue; + } + const fileLines = content.split('\n').filter((l) => l.trim() !== ''); + // Lines within a file are chronological — walk backwards for newest-first. + for (let i = fileLines.length - 1; i >= 0 && events.length < q.limit; i--) { + let event: AuditEvent; + try { + event = JSON.parse(fileLines[i]) as AuditEvent; + // eslint-disable-next-line no-catch-all/no-catch-all -- malformed stored lines are skipped (counted + warned below) + } catch { + malformed++; + continue; + } + if (!matches(event, q)) continue; + events.push(event); + lines.push(fileLines[i]); + } + } + + if (malformed > 0) { + log.warn('Audit reader skipped malformed lines', { malformed }); + } + return { events, lines }; +} + +function dayFilesNewestFirst(): Array<{ day: string; file: string }> { + let entries: string[]; + try { + entries = fs.readdirSync(AUDIT_DIR); + // eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means no events, not an error + } catch { + return []; + } + return entries + .map((e) => DAY_FILE_RE.exec(e)) + .filter((m): m is RegExpExecArray => m !== null) + .map((m) => ({ day: m[1], file: path.join(AUDIT_DIR, m[0]) })) + .sort((a, b) => (a.day < b.day ? 1 : -1)); +} + +function matches(event: AuditEvent, q: AuditQuery): boolean { + if (q.actor !== undefined && event.actor?.id !== q.actor) return false; + if (q.action !== undefined && event.action !== q.action && !event.action?.startsWith(q.action + '.')) return false; + if (q.outcome !== undefined && event.outcome !== q.outcome) return false; + if (q.correlation !== undefined && event.correlation_id !== q.correlation) return false; + if (q.resource !== undefined) { + const hit = (event.resources ?? []).some((r) => r.id === q.resource || r.type === q.resource); + if (!hit) return false; + } + const t = Date.parse(event.time ?? ''); + if (q.sinceMs !== undefined && !(t >= q.sinceMs)) return false; + if (q.untilMs !== undefined && !(t <= q.untilMs)) return false; + return true; +} + +/** + * `ncl audit list` handler. Disabled → an explicit error: an empty list would + * read as "no actions happened", which is a different truth than "not + * recording". `--format ndjson` returns the stored lines verbatim (the human + * formatter passes strings through); default returns flat rows for the table. + */ +export function listAuditEvents(args: Record): string | Array> { + if (!AUDIT_ENABLED) { + throw new Error('audit log is disabled — set AUDIT_ENABLED=true'); + } + + const format = args.format !== undefined ? String(args.format) : ''; + if (format && format !== 'ndjson') { + throw new Error(`invalid --format "${format}" — only "ndjson" is supported`); + } + const outcome = args.outcome !== undefined ? String(args.outcome) : undefined; + if (outcome !== undefined && !OUTCOMES.has(outcome)) { + throw new Error(`invalid --outcome "${outcome}" — one of: ${[...OUTCOMES].join(', ')}`); + } + + const q: AuditQuery = { + actor: args.actor !== undefined ? String(args.actor) : undefined, + action: args.action !== undefined ? String(args.action) : undefined, + resource: args.resource !== undefined ? String(args.resource) : undefined, + outcome: outcome as AuditOutcome | undefined, + correlation: args.correlation !== undefined ? String(args.correlation) : undefined, + sinceMs: args.since !== undefined ? parseTimeFlag(String(args.since), '--since') : undefined, + untilMs: args.until !== undefined ? parseTimeFlag(String(args.until), '--until') : undefined, + limit: args.limit !== undefined ? Math.max(1, Number(args.limit) || DEFAULT_LIMIT) : DEFAULT_LIMIT, + }; + + const { events, lines } = queryAuditEvents(q); + if (format === 'ndjson') return lines.join('\n'); + + return events.map((e) => ({ + time: e.time, + actor: e.actor?.id ?? '', + action: e.action, + resources: (e.resources ?? []).map((r) => (r.id ? `${r.type}:${r.id}` : r.type)).join(' '), + outcome: e.outcome, + correlation: e.correlation_id ?? '', + event_id: e.event_id, + })); +} diff --git a/src/audit/redact.test.ts b/src/audit/redact.test.ts new file mode 100644 index 000000000..68ad88132 --- /dev/null +++ b/src/audit/redact.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { redactDetails } from './redact.js'; + +describe('redactDetails', () => { + it('masks sensitive keys at any depth, case-insensitively', () => { + const out = redactDetails({ + name: 'notion', + env: { NOTION_TOKEN: 'secret-value', SAFE_VALUE: 'ok' }, + nested: { Authorization: 'Bearer abc', list: [{ 'api-key': 'k' }, { plain: 'p' }] }, + password: 'hunter2', + }); + expect(out).toEqual({ + name: 'notion', + env: { NOTION_TOKEN: '[REDACTED]', SAFE_VALUE: 'ok' }, + nested: { Authorization: '[REDACTED]', list: [{ 'api-key': '[REDACTED]' }, { plain: 'p' }] }, + password: '[REDACTED]', + }); + }); + + it('matches the documented key pattern (token|secret|key|password|credential|auth|bearer)', () => { + const out = redactDetails({ + access_token: 'x', + clientSecret: 'x', + ssh_key: 'x', + credentials: 'x', + oauth_flow: 'x', + bearerValue: 'x', + username: 'moshe', + }); + expect(Object.entries(out).filter(([, v]) => v === '[REDACTED]')).toHaveLength(6); + expect(out.username).toBe('moshe'); + }); + + it('never recurses into a masked key — the whole value is replaced', () => { + const out = redactDetails({ auth: { inner: 'visible?' } }); + expect(out.auth).toBe('[REDACTED]'); + }); + + it('truncates strings over 2 KB post-redaction', () => { + const long = 'a'.repeat(5000); + const out = redactDetails({ blob: long, short: 'b' }); + expect(out.blob).toBe('a'.repeat(2048) + '…[truncated]'); + expect(out.short).toBe('b'); + }); + + it('passes non-string scalars through untouched', () => { + const out = redactDetails({ n: 42, b: true, z: null, u: undefined }); + expect(out).toEqual({ n: 42, b: true, z: null, u: undefined }); + }); + + it('caps depth (cycle guard) without throwing', () => { + const cyclic: Record = { a: 1 }; + cyclic.self = cyclic; + const out = redactDetails(cyclic); + let cursor: unknown = out; + for (let i = 0; i < 20 && typeof cursor === 'object' && cursor !== null; i++) { + cursor = (cursor as Record).self; + } + expect(cursor).toBe('[MAX_DEPTH]'); + }); + + it('does not mutate the input', () => { + const input = { token: 'x', nested: { list: ['a'.repeat(3000)] } }; + const snapshot = JSON.parse(JSON.stringify(input)); + redactDetails(input); + expect(input).toEqual(snapshot); + }); +}); diff --git a/src/audit/redact.ts b/src/audit/redact.ts new file mode 100644 index 000000000..4757669f1 --- /dev/null +++ b/src/audit/redact.ts @@ -0,0 +1,39 @@ +/** + * Recursive details redaction — runs at the single emit seam so every current + * and future surface is guarded by default. Two rules: + * 1. Any key matching the sensitive pattern is masked to '[REDACTED]' + * (the value is never inspected or recursed into). + * 2. Strings are truncated to ~2 KB post-redaction. + * + * Message bodies are excluded upstream by the per-surface mappers (shape only: + * body_chars, attachment names) — this mask is defense-in-depth, not the + * mechanism that keeps chat content out of the log. + */ + +const SENSITIVE_KEY = /(token|secret|key|password|credential|auth|bearer)/i; +const MAX_VALUE_CHARS = 2048; +const MAX_DEPTH = 8; + +export function redactDetails(details: Record): Record { + return redactObject(details, 0); +} + +function redactObject(obj: Record, depth: number): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + out[k] = SENSITIVE_KEY.test(k) ? '[REDACTED]' : redactValue(v, depth + 1); + } + return out; +} + +function redactValue(value: unknown, depth: number): unknown { + if (typeof value === 'string') { + return value.length > MAX_VALUE_CHARS ? `${value.slice(0, MAX_VALUE_CHARS)}…[truncated]` : value; + } + if (value === null || typeof value !== 'object') return value; + // Depth cap doubles as a cheap cycle guard — details payloads are + // JSON-serializable in practice, but the emit path must never throw. + if (depth > MAX_DEPTH) return '[MAX_DEPTH]'; + if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1)); + return redactObject(value as Record, depth); +} diff --git a/src/audit/store.test.ts b/src/audit/store.test.ts new file mode 100644 index 000000000..6dc5118a8 --- /dev/null +++ b/src/audit/store.test.ts @@ -0,0 +1,183 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Config + log are mocked so store/emit resolve a per-test temp DATA_DIR and +// audit toggles. Getters keep the values live across vi.resetModules(). +const state = vi.hoisted(() => ({ dataDir: '', enabled: true, retention: 90 })); + +vi.mock('../config.js', () => ({ + get DATA_DIR() { + return state.dataDir; + }, + get AUDIT_ENABLED() { + return state.enabled; + }, + get AUDIT_RETENTION_DAYS() { + return state.retention; + }, +})); + +vi.mock('../log.js', () => ({ + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, +})); + +let store: typeof import('./store.js'); +let emit: typeof import('./emit.js'); +let log: (typeof import('../log.js'))['log']; + +beforeEach(async () => { + state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-store-')); + state.enabled = true; + state.retention = 90; + vi.resetModules(); + store = await import('./store.js'); + emit = await import('./emit.js'); + log = (await import('../log.js')).log; + vi.clearAllMocks(); +}); + +afterEach(() => { + fs.chmodSync(path.join(state.dataDir), 0o700); + const auditDir = path.join(state.dataDir, 'audit'); + if (fs.existsSync(auditDir)) fs.chmodSync(auditDir, 0o700); + fs.rmSync(state.dataDir, { recursive: true, force: true }); +}); + +function auditDir(): string { + return path.join(state.dataDir, 'audit'); +} + +function writeDayFile(day: string, lines = 1): void { + fs.mkdirSync(auditDir(), { recursive: true }); + fs.writeFileSync(path.join(auditDir(), `${day}.ndjson`), '{"x":1}\n'.repeat(lines)); +} + +function dayString(daysAgo: number): string { + return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)); +} + +const EVENT_INPUT = { + actor: { type: 'human' as const, id: 'host:test' }, + origin: { transport: 'socket' as const }, + action: 'groups.list', + resources: [{ type: 'agent_group' }], + outcome: 'success' as const, +}; + +describe('appendAuditLine', () => { + it("appends one line to today's UTC day-file, creating the directory lazily", () => { + expect(fs.existsSync(auditDir())).toBe(false); + store.appendAuditLine('{"a":1}'); + store.appendAuditLine('{"b":2}'); + const file = path.join(auditDir(), `${store.utcDay()}.ndjson`); + expect(fs.readFileSync(file, 'utf8')).toBe('{"a":1}\n{"b":2}\n'); + }); +}); + +describe('emitAuditEvent', () => { + it('writes a schema_version-1 record with envelope fields and null enrichment', () => { + emit.emitAuditEvent({ ...EVENT_INPUT, details: { limit: 100 } }); + const file = path.join(auditDir(), `${store.utcDay()}.ndjson`); + const record = JSON.parse(fs.readFileSync(file, 'utf8').trim()); + expect(record).toMatchObject({ + schema_version: 1, + actor: { type: 'human', id: 'host:test', email: null, user_id: null, group_ids: null }, + origin: { transport: 'socket' }, + action: 'groups.list', + outcome: 'success', + correlation_id: null, + details: { limit: 100 }, + }); + expect(record.event_id).toMatch(/^[0-9a-f-]{36}$/); + expect(new Date(record.time).toISOString()).toBe(record.time); + }); + + it('redacts details at the emit seam', () => { + emit.emitAuditEvent({ ...EVENT_INPUT, details: { env: { API_TOKEN: 'x' } } }); + const file = path.join(auditDir(), `${store.utcDay()}.ndjson`); + expect(fs.readFileSync(file, 'utf8')).toContain('"API_TOKEN":"[REDACTED]"'); + }); + + it('is a no-op when audit is disabled — the directory is never created', () => { + state.enabled = false; + emit.emitAuditEvent(EVENT_INPUT); + expect(fs.existsSync(auditDir())).toBe(false); + }); + + it('fails open and loud when the append fails', () => { + fs.mkdirSync(auditDir(), { recursive: true }); + fs.chmodSync(auditDir(), 0o500); + expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow(); + expect(log.error).toHaveBeenCalledWith( + expect.stringContaining('Audit append failed'), + expect.objectContaining({ action: 'groups.list' }), + ); + }); +}); + +describe('assertAuditWritable', () => { + it('creates the directory and probes it with a zero-byte append', () => { + store.assertAuditWritable(); + expect(fs.existsSync(path.join(auditDir(), `${store.utcDay()}.ndjson`))).toBe(true); + }); + + it('throws when the directory is not writable', () => { + fs.mkdirSync(auditDir(), { recursive: true }); + fs.chmodSync(auditDir(), 0o500); + expect(() => store.assertAuditWritable()).toThrow(); + }); +}); + +describe('pruneAuditLog', () => { + it('unlinks only day-files strictly older than the horizon', () => { + writeDayFile(dayString(100)); + writeDayFile(dayString(91)); + writeDayFile(dayString(90)); + writeDayFile(dayString(1)); + fs.writeFileSync(path.join(auditDir(), 'not-a-day-file.txt'), 'keep'); + fs.writeFileSync(path.join(auditDir(), '2026-01-01.ndjson.bak'), 'keep'); + store.pruneAuditLog(90); + const left = fs.readdirSync(auditDir()).sort(); + expect(left).toEqual( + [`${dayString(90)}.ndjson`, `${dayString(1)}.ndjson`, '2026-01-01.ndjson.bak', 'not-a-day-file.txt'].sort(), + ); + }); + + it('keeps forever when retention is 0', () => { + writeDayFile(dayString(400)); + store.pruneAuditLog(0); + expect(fs.existsSync(path.join(auditDir(), `${dayString(400)}.ndjson`))).toBe(true); + }); + + it('is a no-op when the audit directory does not exist', () => { + expect(() => store.pruneAuditLog(90)).not.toThrow(); + }); +}); + +describe('pruneAuditLogIfDue', () => { + it('prunes at most once per UTC day', () => { + writeDayFile(dayString(100)); + store.pruneAuditLogIfDue(); + expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(false); + writeDayFile(dayString(100)); + store.pruneAuditLogIfDue(); + expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true); + }); + + it('does nothing when audit is disabled', () => { + state.enabled = false; + writeDayFile(dayString(100)); + store.pruneAuditLogIfDue(); + expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true); + }); + + it('does nothing after markPrunedToday until the day rolls over', () => { + store.markPrunedToday(); + writeDayFile(dayString(100)); + store.pruneAuditLogIfDue(); + expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true); + }); +}); diff --git a/src/audit/store.ts b/src/audit/store.ts new file mode 100644 index 000000000..c426a3134 --- /dev/null +++ b/src/audit/store.ts @@ -0,0 +1,96 @@ +/** + * Audit day-file store — the tree's first structured file writer. + * + * Daily NDJSON files under data/audit/, named .ndjson. Append-only is + * structural: nothing in the system can update a line. Retention is unlinking + * whole day-files past the horizon — a literal hard delete, no VACUUM. The + * host process is the single writer by construction (both ncl transports and + * every approval converge host-side). + */ +import fs from 'fs'; +import path from 'path'; + +import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS, DATA_DIR } from '../config.js'; +import { log } from '../log.js'; + +export const AUDIT_DIR = path.join(DATA_DIR, 'audit'); + +const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** UTC day string (YYYY-MM-DD) — day-file names and prune boundaries. */ +export function utcDay(d: Date = new Date()): string { + return d.toISOString().slice(0, 10); +} + +export function dayFilePath(day: string): string { + return path.join(AUDIT_DIR, `${day}.ndjson`); +} + +/** + * The single append point. Throws on fs failure — emitAuditEvent catches + * (fail-open + loud lives there, not here). + */ +export function appendAuditLine(line: string): void { + fs.mkdirSync(AUDIT_DIR, { recursive: true }); + fs.appendFileSync(dayFilePath(utcDay()), line + '\n'); +} + +/** + * Boot-time writability assert — called only when AUDIT_ENABLED. A zero-byte + * append is a true write probe (fs.access can pass on read-only mounts). + * Throws so main() refuses to start rather than run with a silent audit gap. + */ +export function assertAuditWritable(): void { + fs.mkdirSync(AUDIT_DIR, { recursive: true }); + fs.appendFileSync(dayFilePath(utcDay()), ''); +} + +/** + * Unlink day-files strictly older than (today UTC − retentionDays). 0 or + * negative = keep forever. Lexicographic compare is correct for ISO days. + */ +export function pruneAuditLog(retentionDays: number = AUDIT_RETENTION_DAYS): void { + if (retentionDays <= 0) return; + let entries: string[]; + try { + entries = fs.readdirSync(AUDIT_DIR); + // eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means nothing to prune + } catch { + return; // Directory absent — nothing recorded yet. + } + const horizon = utcDay(new Date(Date.now() - retentionDays * DAY_MS)); + let pruned = 0; + for (const entry of entries) { + const m = DAY_FILE_RE.exec(entry); + if (!m || m[1] >= horizon) continue; + try { + fs.unlinkSync(path.join(AUDIT_DIR, entry)); + pruned++; + // eslint-disable-next-line no-catch-all/no-catch-all -- one stubborn file must not stop the rest of the prune + } catch (err) { + log.error('Audit prune failed to unlink day-file', { file: entry, err }); + } + } + if (pruned > 0) { + log.info('Audit retention pruned day-files', { pruned, retentionDays }); + } +} + +// Host-sweep throttle: the sweep ticks every 60s but retention only needs to +// move once per UTC day. +let lastPruneDay: string | null = null; + +/** Sweep hook — no-op unless audit is enabled with a finite retention. */ +export function pruneAuditLogIfDue(): void { + if (!AUDIT_ENABLED || AUDIT_RETENTION_DAYS <= 0) return; + const today = utcDay(); + if (lastPruneDay === today) return; + lastPruneDay = today; + pruneAuditLog(); +} + +/** Called after the boot-time prune so the first sweep tick doesn't re-prune. */ +export function markPrunedToday(): void { + lastPruneDay = utcDay(); +} diff --git a/src/audit/types.ts b/src/audit/types.ts new file mode 100644 index 000000000..ed5f7667a --- /dev/null +++ b/src/audit/types.ts @@ -0,0 +1,64 @@ +/** + * Canonical audit event vocabulary — schema_version 1. + * + * Fields are chosen to project losslessly onto OCSF and Elastic ECS; the + * field mapping lives with the design docs and stays documentation until a + * SIEM forwarder exists. + */ + +export type AuditActorType = 'human' | 'agent' | 'system'; + +export interface AuditActor { + type: AuditActorType; + /** `host:` | `:` | agent group id | `host` (system). */ + id: string; +} + +export interface AuditOrigin { + transport: 'socket' | 'container' | 'channel'; + session_id?: string; + messaging_group_id?: string; + channel?: string; +} + +export interface AuditResource { + type: string; + /** Omitted when only the attempted type is known (e.g. a denied list). */ + id?: string; +} + +export type AuditOutcome = 'success' | 'failure' | 'denied' | 'pending' | 'approved' | 'rejected'; + +/** What emit sites provide. Envelope fields are stamped by emitAuditEvent. */ +export interface AuditEventInput { + actor: AuditActor; + origin: AuditOrigin; + /** Dotted namespaced verb, e.g. `groups.config.add-mcp-server`. */ + action: string; + resources: AuditResource[]; + outcome: AuditOutcome; + /** The approval id on gated chains; null/omitted otherwise. */ + correlationId?: string | null; + details?: Record; +} + +/** + * Stored record — one NDJSON line. The directory-enrichment fields (email, + * user_id, group_ids) ship in the schema but stamp null until the directory + * adapter lands; they are filled at write time, never backfilled. + */ +export interface AuditEvent { + event_id: string; + time: string; + schema_version: 1; + actor: AuditActor & { email: null; user_id: null; group_ids: null }; + origin: AuditOrigin; + action: string; + resources: AuditResource[]; + outcome: AuditOutcome; + correlation_id: string | null; + details: Record; +} + +/** Actor for timer/sweep-driven outcomes (OneCLI expiry, startup sweeps, ghost rejects). */ +export const SYSTEM_ACTOR: AuditActor = { type: 'system', id: 'host' }; diff --git a/src/audit/wrappers.test.ts b/src/audit/wrappers.test.ts new file mode 100644 index 000000000..2321620dd --- /dev/null +++ b/src/audit/wrappers.test.ts @@ -0,0 +1,287 @@ +/** + * Out-of-band seam wrappers: permissions card decisions, the ungated + * create-agent door, and the four OneCLI paths. Inners are stubs — each + * wrapper's contract is "call through, emit the right event (or none)". + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const appended = vi.hoisted(() => ({ lines: [] as string[] })); +const db = vi.hoisted(() => ({ row: undefined as Record | undefined })); + +vi.mock('../config.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, AUDIT_ENABLED: true }; +}); + +vi.mock('./store.js', async (importOriginal) => { + const actual = await importOriginal(); + 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() }, +})); + +vi.mock('../db/sessions.js', () => ({ + getPendingApproval: () => db.row, +})); + +vi.mock('../db/messaging-groups.js', () => ({ + getMessagingGroup: () => undefined, +})); + +import type { Session } from '../types.js'; +import { + auditChannelDecision, + auditChannelNameInterceptor, + auditCreateAgentDirect, + auditOneCliDecision, + auditOneCliExpiry, + auditOneCliHold, + auditOneCliSweep, + auditSenderDecision, +} from './wrappers.js'; + +const PAYLOAD = { + questionId: 'q1', + value: 'approve', + userId: 'U1', + channelType: 'slack', + platformId: 'p', + threadId: null, +}; + +const SESSION: Session = { + id: 'sess-1', + agent_group_id: 'ag-parent', + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-01-01T00:00:00.000Z', +}; + +const ONECLI_ROW = { + approval_id: 'oa-abc123', + agent_group_id: 'ag-1', + channel_type: 'telegram', + payload: JSON.stringify({ + oneCliRequestId: 'uuid-1', + method: 'POST', + host: 'api.notion.com', + path: '/v1/pages', + bodyPreview: 'SECRET BODY CONTENT', + agent: { externalId: 'ag-1', name: 'Agent' }, + approver: 'slack:U0ADMIN', + }), +}; + +function events(): Array> { + return appended.lines.map((l) => JSON.parse(l)); +} + +beforeEach(() => { + appended.lines.length = 0; + db.row = undefined; +}); + +describe('auditSenderDecision', () => { + const decision = { + approved: true, + senderIdentity: 'slack:U0NEW', + agentGroupId: 'ag-1', + messagingGroupId: 'mg-1', + approverId: 'slack:U0ADMIN', + channelType: 'slack', + }; + + it('emits senders.allow success on approve and coerces claimed', async () => { + const wrapped = auditSenderDecision(async () => ({ claimed: true, decision })); + expect(await wrapped(PAYLOAD)).toBe(true); + const [event] = events(); + expect(event).toMatchObject({ + actor: { type: 'human', id: 'slack:U0ADMIN' }, + origin: { transport: 'channel', channel: 'slack' }, + action: 'senders.allow', + outcome: 'success', + correlation_id: null, + details: {}, + }); + expect(event.resources).toEqual([ + { type: 'user', id: 'slack:U0NEW' }, + { type: 'agent_group', id: 'ag-1' }, + { type: 'messaging_group', id: 'mg-1' }, + ]); + }); + + it('emits rejected on deny', async () => { + const wrapped = auditSenderDecision(async () => ({ claimed: true, decision: { ...decision, approved: false } })); + await wrapped(PAYLOAD); + expect(events()[0].outcome).toBe('rejected'); + }); + + it('emits nothing without a decision (unclaimed or unauthorized click)', async () => { + const unclaimed = auditSenderDecision(async () => ({ claimed: false })); + const unauthorized = auditSenderDecision(async () => ({ claimed: true })); + expect(await unclaimed(PAYLOAD)).toBe(false); + expect(await unauthorized(PAYLOAD)).toBe(true); + expect(appended.lines).toHaveLength(0); + }); +}); + +describe('auditChannelDecision / auditChannelNameInterceptor', () => { + const base = { messagingGroupId: 'mg-1', approverId: 'slack:U0ADMIN', channelType: 'slack' }; + + it('maps connected → success with the wired agent group', async () => { + const wrapped = auditChannelDecision(async () => ({ + claimed: true, + decision: { kind: 'connected' as const, agentGroupId: 'ag-1', createdAgentGroup: false, ...base }, + })); + await wrapped(PAYLOAD); + const [event] = events(); + expect(event).toMatchObject({ + action: 'channels.register', + outcome: 'success', + details: { created_agent_group: false }, + }); + expect(event.resources).toEqual([ + { type: 'messaging_group', id: 'mg-1' }, + { type: 'agent_group', id: 'ag-1' }, + ]); + }); + + it('maps rejected and failed outcomes', async () => { + await auditChannelDecision(async () => ({ claimed: true, decision: { kind: 'rejected' as const, ...base } }))( + PAYLOAD, + ); + await auditChannelDecision(async () => ({ + claimed: true, + decision: { kind: 'failed' as const, reason: 'target agent group no longer exists', ...base }, + }))(PAYLOAD); + const all = events(); + expect(all[0]).toMatchObject({ action: 'channels.register', outcome: 'rejected' }); + expect(all[1]).toMatchObject({ action: 'channels.register', outcome: 'failure' }); + expect(all[1].details.reason).toContain('no longer exists'); + }); + + it('records the interceptor-created agent group — the third creation door', async () => { + const wrapped = auditChannelNameInterceptor(async () => ({ + claimed: true, + decision: { + kind: 'connected' as const, + agentGroupId: 'ag-new', + createdAgentGroup: true, + agentName: 'Scout', + ...base, + }, + })); + expect(await wrapped({} as never)).toBe(true); + const [event] = events(); + expect(event.details).toMatchObject({ created_agent_group: true, agent_name: 'Scout' }); + expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-new' }); + }); +}); + +describe('auditCreateAgentDirect', () => { + it('emits agents.create success naming parent and child', async () => { + const wrapped = auditCreateAgentDirect(async () => ({ + ok: true, + agentGroupId: 'ag-child', + name: 'Scout', + localName: 'scout', + folder: 'scout', + })); + const result = await wrapped('Scout', 'be helpful', SESSION, { id: 'ag-parent' } as never, () => {}); + expect(result.ok).toBe(true); + const [event] = events(); + expect(event).toMatchObject({ + actor: { type: 'agent', id: 'ag-parent' }, + action: 'agents.create', + outcome: 'success', + correlation_id: null, + details: { name: 'Scout', parent: 'ag-parent', folder: 'scout', instructions_chars: 10 }, + }); + expect(event.resources).toEqual([ + { type: 'agent_group', id: 'ag-parent' }, + { type: 'agent_group', id: 'ag-child' }, + ]); + }); + + it('emits failure with the reason when creation is refused', async () => { + const wrapped = auditCreateAgentDirect(async () => ({ ok: false, reason: 'destination name collision' })); + await wrapped('Scout', null, SESSION, { id: 'ag-parent' } as never, () => {}); + const [event] = events(); + expect(event).toMatchObject({ outcome: 'failure', details: { reason: 'destination name collision' } }); + }); +}); + +describe('OneCLI wrappers', () => { + it('hold: emits pending from the row — shape only, never the body preview', () => { + const inner = vi.fn(); + auditOneCliHold(inner)(ONECLI_ROW); + expect(inner).toHaveBeenCalledOnce(); + const [event] = events(); + expect(event).toMatchObject({ + actor: { type: 'agent', id: 'ag-1' }, + origin: { transport: 'container' }, + action: 'onecli.credential.use', + outcome: 'pending', + correlation_id: 'oa-abc123', + details: { method: 'POST', host: 'api.notion.com', path: '/v1/pages', body_preview_chars: 19 }, + }); + expect(event.resources).toContainEqual({ type: 'user', id: 'slack:U0ADMIN' }); + expect(JSON.stringify(event)).not.toContain('SECRET BODY CONTENT'); + }); + + it('decision: emits approvals.decide with the clicking human when resolved', () => { + db.row = ONECLI_ROW; + const wrapped = auditOneCliDecision(() => true); + expect(wrapped('oa-abc123', 'approve', 'slack:U0ADMIN')).toBe(true); + const [event] = events(); + expect(event).toMatchObject({ + actor: { type: 'human', id: 'slack:U0ADMIN' }, + origin: { transport: 'channel', channel: 'slack' }, + action: 'approvals.decide', + outcome: 'approved', + correlation_id: 'oa-abc123', + details: { gated_action: 'onecli.credential.use' }, + }); + }); + + it('decision: emits nothing when the resolver is gone (inner returns false)', () => { + db.row = ONECLI_ROW; + expect(auditOneCliDecision(() => false)('oa-abc123', 'approve', 'slack:U0ADMIN')).toBe(false); + expect(appended.lines).toHaveLength(0); + }); + + it('expiry: the system actor rejects with the reason', async () => { + db.row = ONECLI_ROW; + await auditOneCliExpiry(async () => {})('oa-abc123', 'no response'); + const [event] = events(); + expect(event).toMatchObject({ + actor: { type: 'system', id: 'host' }, + origin: { transport: 'channel', channel: 'telegram' }, + outcome: 'rejected', + details: { reason: 'expired: no response' }, + }); + }); + + it('sweep: one system rejection per orphaned row', async () => { + const rows = [ONECLI_ROW, { ...ONECLI_ROW, approval_id: 'oa-second' }]; + await auditOneCliSweep( + async () => {}, + () => rows as never, + )(); + const all = events(); + expect(all).toHaveLength(2); + expect(all.map((e) => e.correlation_id)).toEqual(['oa-abc123', 'oa-second']); + expect(all.every((e) => e.actor.type === 'system' && e.details.reason === 'host restarted')).toBe(true); + }); +}); diff --git a/src/audit/wrappers.ts b/src/audit/wrappers.ts new file mode 100644 index 000000000..5318831e5 --- /dev/null +++ b/src/audit/wrappers.ts @@ -0,0 +1,365 @@ +/** + * Audit wrapper factories — the only way audit events get emitted. Each + * instrumented seam composes one of these at its module edge; business logic + * stays free of audit calls. + */ +import type { InboundEvent } from '../channels/adapter.js'; +import type { CallerContext, RequestFrame, ResponseFrame } from '../cli/frame.js'; +import type { DispatchOptions, DispatchTrace } from '../cli/dispatch.js'; +import { getPendingApproval } from '../db/sessions.js'; +// Type-only imports from the approvals module — erased at runtime, so +// primitive.ts importing this file back is not a cycle. +import type { + ApprovalHandler, + ApprovalHandlerContext, + ApprovalHold, + RequestApprovalOptions, +} from '../modules/approvals/primitive.js'; +import type { CreateAgentResult } from '../modules/agent-to-agent/create-agent.js'; +import type { ResponsePayload } from '../response-registry.js'; +import type { AgentGroup, PendingApproval, Session } from '../types.js'; +import { emitAuditEvent } from './emit.js'; +import { + actorForCaller, + auditActionForApproval, + channelOriginForUser, + detailsForApprovalPayload, + humanOrSystemActor, + normalizeArgKeys, + oneCliDecideEvent, + oneCliHoldEvent, + originForCaller, + originForSession, + resourcesForApproval, + resourcesForCli, +} from './mapping.js'; +import { type AuditOutcome, type AuditResource, SYSTEM_ACTOR } from './types.js'; + +type DispatchInner = (req: RequestFrame, ctx: CallerContext, opts?: DispatchOptions) => Promise; + +/** + * Dispatch middleware — the exported `dispatch` is the wrapped function, so + * the socket server, the container delivery-action, and the approved replay + * are all covered without changing a call site. + * + * Outcome derives from the response frame: ok → success, forbidden → denied + * (captures pre-handler scope denials), anything else → failure. + * approval-pending responses are skipped — the requestApproval decorator owns + * every pending event. On replays, opts.approvalId becomes the terminal + * event's correlation_id. + */ +export function withAudit(inner: DispatchInner): DispatchInner { + return async (req, ctx, opts = {}) => { + // Trace out-param: dispatch reassigns `req` internally (dash-id fallback, + // group auto-fill), so the resolved command + effective args surface here. + const trace: DispatchTrace = {}; + const res = await inner(req, ctx, { ...opts, trace }); + + if (!res.ok && res.error.code === 'approval-pending') return res; + + emitAuditEvent(() => { + const cmd = trace.cmd; + const args = normalizeArgKeys(trace.args ?? req.args); + const outcome: AuditOutcome = res.ok ? 'success' : res.error.code === 'forbidden' ? 'denied' : 'failure'; + const details: Record = { ...args }; + if (!res.ok) { + details.error = res.error.code; + details.reason = res.error.message; + } + if (!cmd) details.command = trace.command ?? req.command; + return { + actor: actorForCaller(ctx), + origin: originForCaller(ctx), + action: cmd ? cmd.action : 'cli.unknown-command', + resources: cmd ? resourcesForCli(cmd, args) : [], + outcome, + correlationId: opts.approvalId ?? null, + details, + }; + }); + return res; + }; +} + +type RequestApprovalInner = (opts: RequestApprovalOptions) => Promise; + +/** + * requestApproval decorator — owns every pending event, for all gated + * surfaces at once: only this seam holds the action, payload, minted approval + * id, and the approver the card actually went to. No hold (no approver, no DM + * target, delivery failure) → no event. + */ +export function auditRequestApproval(inner: RequestApprovalInner): (opts: RequestApprovalOptions) => Promise { + return async (opts) => { + const hold = await inner(opts); + if (!hold) return; + emitAuditEvent(() => ({ + actor: { type: 'agent', id: opts.session.agent_group_id }, + origin: originForSession(opts.session), + action: auditActionForApproval(opts.action, opts.payload), + resources: [ + ...resourcesForApproval(opts.action, opts.payload, opts.session), + { type: 'approval', id: hold.approvalId }, + // Who was asked: the picked approver is part of the record. + { type: 'user', id: hold.approverUserId }, + ], + outcome: 'pending', + correlationId: hold.approvalId, + details: detailsForApprovalPayload(opts.action, opts.payload), + })); + }; +} + +/** + * Post-approve handler runner — emits the terminal event of a gated chain + * (success/failure from whether the handler threw), correlated by the + * approval id. cli_command is skipped: its replay goes back through the + * dispatch middleware, which alone sees the real response frame. Rethrows so + * the response handler's own catch/notify behavior is unchanged. + */ +export async function runApprovedHandler( + handler: ApprovalHandler, + ctx: ApprovalHandlerContext, + approval: PendingApproval, + session: Session, +): Promise { + const skip = approval.action === 'cli_command'; + const terminal = (outcome: AuditOutcome, error?: string): void => { + if (skip) return; + emitAuditEvent(() => ({ + actor: { type: 'agent', id: session.agent_group_id }, + origin: originForSession(session), + action: auditActionForApproval(approval.action, ctx.payload), + resources: [ + ...resourcesForApproval(approval.action, ctx.payload, session), + { type: 'approval', id: approval.approval_id }, + ], + outcome, + correlationId: approval.approval_id, + details: error + ? { ...detailsForApprovalPayload(approval.action, ctx.payload), error } + : detailsForApprovalPayload(approval.action, ctx.payload), + })); + }; + + try { + await handler(ctx); + } catch (err) { + terminal('failure', err instanceof Error ? err.message : String(err)); + throw err; + } + terminal('success'); +} + +// ── Permissions card handlers (senders.allow / channels.register) ── +// The inner handlers return domain descriptors; these adapters emit the event +// and coerce back to the boolean the response registry / router expect. + +export interface SenderDecision { + approved: boolean; + senderIdentity: string; + agentGroupId: string; + messagingGroupId: string; + approverId: string; + channelType: string; +} +export type SenderApprovalResult = { claimed: boolean; decision?: SenderDecision }; + +export function auditSenderDecision( + inner: (payload: ResponsePayload) => Promise, +): (payload: ResponsePayload) => Promise { + return async (payload) => { + const result = await inner(payload); + const d = result.decision; + if (d) { + emitAuditEvent(() => ({ + actor: { type: 'human', id: d.approverId }, + origin: { transport: 'channel', channel: d.channelType || undefined }, + action: 'senders.allow', + resources: [ + { type: 'user', id: d.senderIdentity }, + { type: 'agent_group', id: d.agentGroupId }, + { type: 'messaging_group', id: d.messagingGroupId }, + ], + outcome: d.approved ? 'success' : 'rejected', + // The withheld message is never read here — ids only. + details: {}, + })); + } + return result.claimed; + }; +} + +export interface ChannelDecision { + kind: 'connected' | 'rejected' | 'failed'; + messagingGroupId: string; + approverId: string; + channelType?: string; + agentGroupId?: string; + createdAgentGroup?: boolean; + agentName?: string; + reason?: string; +} +export type ChannelApprovalResult = { claimed: boolean; decision?: ChannelDecision }; + +function emitChannelDecision(d: ChannelDecision): void { + emitAuditEvent(() => { + const resources: AuditResource[] = [{ type: 'messaging_group', id: d.messagingGroupId }]; + if (d.agentGroupId) resources.push({ type: 'agent_group', id: d.agentGroupId }); + const details: Record = {}; + if (d.createdAgentGroup !== undefined) details.created_agent_group = d.createdAgentGroup; + if (d.agentName) details.agent_name = d.agentName; + if (d.reason) details.reason = d.reason; + return { + actor: { type: 'human', id: d.approverId }, + origin: { transport: 'channel', channel: d.channelType || undefined }, + action: 'channels.register', + resources, + outcome: d.kind === 'connected' ? 'success' : d.kind === 'rejected' ? 'rejected' : 'failure', + details, + }; + }); +} + +export function auditChannelDecision( + inner: (payload: ResponsePayload) => Promise, +): (payload: ResponsePayload) => Promise { + return async (payload) => { + const result = await inner(payload); + if (result.decision) emitChannelDecision(result.decision); + return result.claimed; + }; +} + +/** The free-text name reply — the third agent-creation door (new group + wiring). */ +export function auditChannelNameInterceptor( + inner: (event: InboundEvent) => Promise, +): (event: InboundEvent) => Promise { + return async (event) => { + const result = await inner(event); + if (result.decision) emitChannelDecision(result.decision); + return result.claimed; + }; +} + +// ── agents.create — the ungated door ── + +type PerformCreateAgentFn = ( + name: string, + instructions: string | null, + session: Session, + sourceGroup: AgentGroup, + notify: (text: string) => void, +) => Promise; + +/** + * Wraps ONLY the global-scope direct call. The approval-gated path is covered + * by the pending/decide/terminal chain (runApprovedHandler wraps + * applyCreateAgent's run) — wrapping the shared body would double-emit. + */ +export function auditCreateAgentDirect(inner: PerformCreateAgentFn): PerformCreateAgentFn { + return async (name, instructions, session, sourceGroup, notify) => { + const result = await inner(name, instructions, session, sourceGroup, notify); + emitAuditEvent(() => { + const resources: AuditResource[] = [{ type: 'agent_group', id: sourceGroup.id }]; + const details: Record = { + name, + parent: sourceGroup.id, + instructions_chars: instructions ? instructions.length : 0, + }; + if (result.ok) { + resources.push({ type: 'agent_group', id: result.agentGroupId }); + details.folder = result.folder; + } else { + details.reason = result.reason; + } + return { + actor: { type: 'agent', id: session.agent_group_id }, + origin: originForSession(session), + action: 'agents.create', + resources, + outcome: result.ok ? 'success' : 'failure', + details, + }; + }); + return result; + }; +} + +// ── OneCLI credential approvals (hold + three resolution paths) ── + +interface OneCliHoldRow { + approval_id: string; + agent_group_id?: string | null; + payload: string; +} + +/** Wraps the hold's row insert — emits the pending event derived from the row. */ +export function auditOneCliHold(inner: (row: T) => void): (row: T) => void { + return (row) => { + inner(row); + emitAuditEvent(() => oneCliHoldEvent(row)); + }; +} + +/** + * Wraps the card-click resolver. The resolving human's id arrives here (the + * inner resolver has no use for it); the row is pre-read because the inner + * deletes it on resolution. + */ +export function auditOneCliDecision( + inner: (approvalId: string, selectedOption: string) => boolean, +): (approvalId: string, selectedOption: string, userId: string) => boolean { + return (approvalId, selectedOption, userId) => { + const row = getPendingApproval(approvalId); + const resolved = inner(approvalId, selectedOption); + if (resolved && row) { + emitAuditEvent(() => + oneCliDecideEvent(row, { + actor: humanOrSystemActor(userId), + origin: channelOriginForUser(userId), + outcome: selectedOption === 'approve' ? 'approved' : 'rejected', + }), + ); + } + return resolved; + }; +} + +/** Wraps the expiry timer's finalizer — the system actor rejects. */ +export function auditOneCliExpiry( + inner: (approvalId: string, reason: string) => Promise, +): (approvalId: string, reason: string) => Promise { + return async (approvalId, reason) => { + const row = getPendingApproval(approvalId); + await inner(approvalId, reason); + if (row) { + emitAuditEvent(() => + oneCliDecideEvent(row, { + actor: SYSTEM_ACTOR, + origin: { transport: 'channel', channel: row.channel_type ?? undefined }, + outcome: 'rejected', + reason: `expired: ${reason}`, + }), + ); + } + }; +} + +/** Wraps the startup sweep — one system rejection per orphaned row. */ +export function auditOneCliSweep(inner: () => Promise, listRows: () => PendingApproval[]): () => Promise { + return async () => { + const rows = listRows(); + await inner(); + for (const row of rows) { + emitAuditEvent(() => + oneCliDecideEvent(row, { + actor: SYSTEM_ACTOR, + origin: { transport: 'channel', channel: row.channel_type ?? undefined }, + outcome: 'rejected', + reason: 'host restarted', + }), + ); + } + }; +} diff --git a/src/cli/crud.ts b/src/cli/crud.ts index a87d71ef8..5a5aca59e 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -232,6 +232,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.list) { register({ name: `${def.plural}-list`, + action: `${def.plural}.list`, description: `List all ${def.plural}.`, access: def.operations.list, resource: def.plural, @@ -244,6 +245,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.get) { register({ name: `${def.plural}-get`, + action: `${def.plural}.get`, description: `Get a ${def.name} by ID.`, access: def.operations.get, resource: def.plural, @@ -256,6 +258,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.create) { register({ name: `${def.plural}-create`, + action: `${def.plural}.create`, description: `Create a new ${def.name}.`, access: def.operations.create, resource: def.plural, @@ -267,6 +270,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.update) { register({ name: `${def.plural}-update`, + action: `${def.plural}.update`, description: `Update a ${def.name}.`, access: def.operations.update, resource: def.plural, @@ -278,6 +282,7 @@ export function registerResource(def: ResourceDef): void { if (def.operations.delete) { register({ name: `${def.plural}-delete`, + action: `${def.plural}.delete`, description: `Delete a ${def.name}.`, access: def.operations.delete, resource: def.plural, @@ -291,6 +296,7 @@ export function registerResource(def: ResourceDef): void { for (const [verb, op] of Object.entries(def.customOperations)) { register({ name: `${def.plural}-${verb.replace(/ /g, '-')}`, + action: `${def.plural}.${verb.replace(/ /g, '.')}`, description: op.description, access: op.access, resource: def.plural, diff --git a/src/cli/dispatch.audit.test.ts b/src/cli/dispatch.audit.test.ts new file mode 100644 index 000000000..751c68f6b --- /dev/null +++ b/src/cli/dispatch.audit.test.ts @@ -0,0 +1,222 @@ +/** + * 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(); + return { ...actual, AUDIT_ENABLED: true }; +}); + +vi.mock('../audit/store.js', async (importOriginal) => { + const actual = await importOriginal(); + 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> { + 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' }); + }); +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index f0dc430cb..48747caed 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -6,20 +6,36 @@ * Approval gating for risky calls from the container is the only branch * that differs by caller. Host callers and `open` commands run inline. */ +import { withAudit } from '../audit/wrappers.js'; import { getContainerConfig } from '../db/container-configs.js'; import { getAgentGroup } from '../db/agent-groups.js'; import { getSession } from '../db/sessions.js'; import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js'; import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { getResource } from './crud.js'; -import { lookup } from './registry.js'; +import { type CommandDef, lookup } from './registry.js'; -type DispatchOptions = { - /** True when a command is being replayed after approval. */ - approved?: boolean; +/** + * Out-param for the audit middleware: dispatch reassigns `req` internally + * (dash-joined id fallback, group-scope auto-fill), so the wrapper can't see + * the resolved command or effective args on the frame it passed in. + */ +export type DispatchTrace = { + cmd?: CommandDef; + command?: string; + args?: Record; }; -export async function dispatch( +export type DispatchOptions = { + /** True when a command is being replayed after approval. */ + approved?: boolean; + /** Approval id when replaying — becomes the terminal audit event's correlation_id. */ + approvalId?: string; + /** Filled by dispatch for the audit middleware. */ + trace?: DispatchTrace; +}; + +async function dispatchInner( req: RequestFrame, ctx: CallerContext, opts: DispatchOptions = {}, @@ -48,6 +64,12 @@ export async function dispatch( } } + if (opts.trace) { + opts.trace.cmd = cmd; + opts.trace.command = req.command; + opts.trace.args = req.args; + } + if (!cmd) { return err(req.id, 'unknown-command', `no command "${req.command}"`); } @@ -102,6 +124,7 @@ export async function dispatch( fill.id = req.args.id ?? ctx.agentGroupId; } req = { ...req, args: { ...req.args, ...fill } }; + if (opts.trace) opts.trace.args = req.args; // Fail-closed pre-handler check for sessions-get: returns "not found" // regardless of whether the UUID exists in another group, preventing an @@ -192,10 +215,19 @@ export async function dispatch( } } -registerApprovalHandler('cli_command', async ({ payload, notify }) => { +/** + * Public dispatcher — the audit middleware wraps the inner dispatcher, so all + * three call sites (socket server, container delivery-action, and the + * approved replay below) emit audit events with zero caller changes. + */ +export const dispatch = withAudit(dispatchInner); + +registerApprovalHandler('cli_command', async ({ payload, notify, approvalId }) => { const frame = payload.frame as RequestFrame; const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' }; - const response = await dispatch(frame, callerContext, { approved: true }); + // approvalId threads through to the audit middleware: the replay's terminal + // event carries the gated chain's correlation_id, emitted there — not here. + const response = await dispatch(frame, callerContext, { approved: true, approvalId }); if (response.ok) { const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 8c0019d97..02030bc41 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -31,18 +31,29 @@ export type CommandDef = { * Custom operations return ad-hoc shapes and leave this undefined. */ generic?: 'list' | 'get'; + /** + * Dotted audit action name, e.g. `groups.config.add-mcp-server`. Stamped + * explicitly by `registerResource` (which knows verb segment boundaries); + * hand-registered commands may omit it and get `name` with dashes→dots. + */ + action: string; /** Validates `frame.args` and produces the typed handler input. Throws on invalid. */ parseArgs: (raw: Record) => TArgs; handler: (args: TArgs, ctx: CallerContext) => Promise; }; +/** `register()` input — `action` is defaulted, everything else as stored. */ +export type CommandInput = Omit, 'action'> & { + action?: string; +}; + const registry = new Map(); -export function register(def: CommandDef): void { +export function register(def: CommandInput): void { if (registry.has(def.name)) { throw new Error(`CLI command "${def.name}" already registered`); } - registry.set(def.name, def as CommandDef); + registry.set(def.name, { ...def, action: def.action ?? def.name.replace(/-/g, '.') } as CommandDef); } export function lookup(name: string): CommandDef | undefined { diff --git a/src/cli/resources/audit.ts b/src/cli/resources/audit.ts new file mode 100644 index 000000000..5df270793 --- /dev/null +++ b/src/cli/resources/audit.ts @@ -0,0 +1,50 @@ +/** + * `ncl audit` — read-only query surface over the local audit log + * (append-only NDJSON day-files under data/audit/, see src/audit/). + * + * Scope: host callers and global-scope agents only. `audit` is deliberately + * NOT on the dispatcher's group-scope allowlist, so group-scoped agents are + * refused before any handler runs (fails closed) — the log spans groups. + * Requires AUDIT_ENABLED=true; a disabled box errors rather than returning an + * empty list that would read as "no actions happened". + */ +import { listAuditEvents } from '../../audit/reader.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'audit_event', + plural: 'audit', + // File-backed, not a table — safe because no generic operations are + // declared, so nothing ever queries this name. + table: '(data/audit/*.ndjson)', + description: 'Local audit log — one event per ncl command and host-routed approval. Newest first.', + idColumn: 'event_id', + columns: [ + { + name: 'actor', + type: 'string', + description: 'Filter: exact actor id (host:, :, agent group id)', + }, + { name: 'action', type: 'string', description: 'Filter: exact action or dotted prefix (e.g. groups.config)' }, + { name: 'resource', type: 'string', description: 'Filter: matches any event resource by id or type' }, + { + name: 'outcome', + type: 'string', + description: 'Filter: event outcome', + enum: ['success', 'failure', 'denied', 'pending', 'approved', 'rejected'], + }, + { name: 'since', type: 'string', description: 'Window start: 7d / 24h / 30m relative, or ISO date' }, + { name: 'until', type: 'string', description: 'Window end: same formats as --since' }, + { name: 'correlation', type: 'string', description: 'Filter: approval id tying a gated chain together' }, + { name: 'limit', type: 'number', description: 'Max events (default 100, newest first)' }, + { name: 'format', type: 'string', description: 'Output format', enum: ['ndjson'] }, + ], + operations: {}, + customOperations: { + list: { + access: 'open', + description: 'Query audit events, newest first. --format ndjson streams the stored lines for SIEM export.', + handler: async (args) => listAuditEvents(args), + }, + }, +}); diff --git a/src/cli/resources/index.ts b/src/cli/resources/index.ts index 693b37790..2d27cf62e 100644 --- a/src/cli/resources/index.ts +++ b/src/cli/resources/index.ts @@ -14,3 +14,4 @@ import './user-dms.js'; import './dropped-messages.js'; import './approvals.js'; import './sessions.js'; +import './audit.js'; diff --git a/src/config.ts b/src/config.ts index c72597353..8152aca59 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,8 @@ const envConfig = readEnvFile([ 'NANOCLAW_EGRESS_LOCKDOWN', 'NANOCLAW_EGRESS_NETWORK', 'ONECLI_GATEWAY_CONTAINER', + 'AUDIT_ENABLED', + 'AUDIT_RETENTION_DAYS', ]); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; @@ -64,6 +66,14 @@ export const EGRESS_NETWORK = export const ONECLI_GATEWAY_CONTAINER = process.env.ONECLI_GATEWAY_CONTAINER || envConfig.ONECLI_GATEWAY_CONTAINER || 'onecli'; +// Local audit log — opt-in (docs/SECURITY.md, "Local Audit Log"). Off by +// default: the audit emitter no-ops and data/audit/ is never created. +export const AUDIT_ENABLED = (process.env.AUDIT_ENABLED || envConfig.AUDIT_ENABLED) === 'true'; +// Audit day-files older than this many days are unlinked (a hard delete). +// 0 = keep forever. Read only when AUDIT_ENABLED=true. +const auditRetentionRaw = parseInt(process.env.AUDIT_RETENTION_DAYS || envConfig.AUDIT_RETENTION_DAYS || '90', 10); +export const AUDIT_RETENTION_DAYS = Number.isNaN(auditRetentionRaw) ? 90 : auditRetentionRaw; + // Timezone for scheduled tasks, message formatting, etc. // Validates each candidate is a real IANA identifier before accepting. function resolveConfigTimezone(): string { diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 56848f642..702c7a11b 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -43,6 +43,7 @@ import { syncProcessingAcks, type ContainerState, } from './db/session-db.js'; +import { pruneAuditLogIfDue } from './audit/index.js'; import { log } from './log.js'; import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbeatPath } from './session-manager.js'; import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; @@ -164,6 +165,14 @@ async function sweep(): Promise { } // MODULE-HOOK:approvals-reason-sweep:end + // Audit retention — unlink day-files past AUDIT_RETENTION_DAYS. Throttled + // to once per UTC day inside the module; no-op when audit is disabled. + try { + pruneAuditLogIfDue(); + } catch (err) { + log.error('Audit prune failed', { err }); + } + setTimeout(sweep, SWEEP_INTERVAL_MS); } diff --git a/src/index.ts b/src/index.ts index 72887f32b..e9e65c467 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import path from 'path'; import { backfillContainerConfigs } from './backfill-container-configs.js'; import { DATA_DIR } from './config.js'; import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; +import { initAuditLog } from './audit/index.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; @@ -82,6 +83,10 @@ async function main(): Promise { // 1c. One-time filesystem cutover — idempotent, no-op after first run. migrateGroupsToClaudeLocal(); + // 1d. Audit log — registers the decision observer; when enabled, asserts + // data/audit/ is writable (throw → exit 1) and runs the boot prune. + initAuditLog(); + // 2. Container runtime ensureContainerRuntimeRunning(); cleanupOrphans(); diff --git a/src/modules/agent-to-agent/create-agent.ts b/src/modules/agent-to-agent/create-agent.ts index b8044da39..3cb800e95 100644 --- a/src/modules/agent-to-agent/create-agent.ts +++ b/src/modules/agent-to-agent/create-agent.ts @@ -14,6 +14,7 @@ */ import path from 'path'; +import { auditCreateAgentDirect } from '../../audit/wrappers.js'; import { GROUPS_DIR } from '../../config.js'; import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js'; import { getContainerConfig, updateContainerConfigScalars } from '../../db/container-configs.js'; @@ -75,7 +76,7 @@ export async function handleCreateAgent(content: Record, sessio const cliScope = getContainerConfig(session.agent_group_id)?.cli_scope ?? 'group'; if (cliScope === 'global') { // Trusted owner agent group — create directly, then notify (+wake) it. - await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text)); + await performCreateAgentDirect(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text)); return; } @@ -112,6 +113,11 @@ export const applyCreateAgent: ApprovalHandler = async ({ session, payload, noti await performCreateAgent(name, instructions, session, sourceGroup, notify); }; +/** What creation did — consumed by the ungated door's audit wrapper. */ +export type CreateAgentResult = + | { ok: true; agentGroupId: string; name: string; localName: string; folder: string } + | { ok: false; reason: string }; + /** * Core creation: writes the new agent group + bidirectional destinations and * scaffolds its filesystem, then reports via `notify`. Authorization is the @@ -126,13 +132,13 @@ async function performCreateAgent( session: Session, sourceGroup: AgentGroup, notify: (text: string) => void, -): Promise { +): Promise { const localName = normalizeName(name); // Collision in the creator's destination namespace if (getDestinationByName(sourceGroup.id, localName)) { notify(`Cannot create agent "${name}": you already have a destination named "${localName}".`); - return; + return { ok: false, reason: 'destination name collision' }; } // Derive a safe folder name, deduplicated globally across agent_groups.folder @@ -149,7 +155,7 @@ async function performCreateAgent( if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) { notify(`Cannot create agent "${name}": invalid folder path.`); log.error('create_agent path traversal attempt', { folder, resolvedPath }); - return; + return { ok: false, reason: 'invalid folder path' }; } const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -208,4 +214,12 @@ async function performCreateAgent( notify(`Agent "${localName}" created. You can now message it with ....`); log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id }); + return { ok: true, agentGroupId, name, localName, folder }; } + +/** + * The ungated door: only the global-scope direct call is audit-wrapped. The + * approval path's terminal event comes from the wrapped handler run in the + * response handler — wrapping the shared body would double-emit. + */ +const performCreateAgentDirect = auditCreateAgentDirect(performCreateAgent); diff --git a/src/modules/agent-to-agent/message-gate.test.ts b/src/modules/agent-to-agent/message-gate.test.ts index fa1a485f6..e5e9d9b88 100644 --- a/src/modules/agent-to-agent/message-gate.test.ts +++ b/src/modules/agent-to-agent/message-gate.test.ts @@ -165,6 +165,7 @@ describe('agent message policies', () => { await applyA2aMessageGate({ session: SA, userId: 'slack:dana', + approvalId: 'appr-test', notify, payload: { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null }, }); diff --git a/src/modules/approvals/onecli-approvals.ts b/src/modules/approvals/onecli-approvals.ts index 6d7183b88..411e7f632 100644 --- a/src/modules/approvals/onecli-approvals.ts +++ b/src/modules/approvals/onecli-approvals.ts @@ -19,6 +19,7 @@ */ import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk'; +import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from '../../audit/wrappers.js'; import { pickApprovalDelivery, pickApprover } from './primitive.js'; import { ONECLI_API_KEY, ONECLI_URL } from '../../config.js'; import { getAgentGroup } from '../../db/agent-groups.js'; @@ -64,8 +65,10 @@ function shortApprovalId(): string { return `oa-${Math.random().toString(36).slice(2, 10)}`; } -/** Called from the approvals response handler when a card button is clicked. */ -export function resolveOneCLIApproval(approvalId: string, selectedOption: string): boolean { +/** Row insert for a hold — the audit wrapper emits the pending event from it. */ +const recordOneCliHold = auditOneCliHold(createPendingApproval); + +function resolveOneCLIApprovalInner(approvalId: string, selectedOption: string): boolean { const state = pending.get(approvalId); if (!state) return false; pending.delete(approvalId); @@ -82,6 +85,14 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string return true; } +/** + * Called from the approvals response handler when a card button is clicked. + * The audit wrapper records the decision with the clicking admin as actor + * (OneCLI rows never reach notifyApprovalResolved, so the shared observer + * can't cover them). + */ +export const resolveOneCLIApproval = auditOneCliDecision(resolveOneCLIApprovalInner); + export function startOneCLIApprovalHandler(deliveryAdapter: ChannelDeliveryAdapter): void { if (handle) return; adapterRef = deliveryAdapter; @@ -170,7 +181,7 @@ async function handleRequest(request: ApprovalRequest): Promise { return 'deny'; } - createPendingApproval({ + recordOneCliHold({ approval_id: approvalId, session_id: null, request_id: request.id, @@ -214,7 +225,7 @@ async function handleRequest(request: ApprovalRequest): Promise { }); } -async function expireApproval(approvalId: string, reason: string): Promise { +async function expireApprovalInner(approvalId: string, reason: string): Promise { const rows = getPendingApprovalsByAction(ONECLI_ACTION).filter((r) => r.approval_id === approvalId); const row = rows[0]; if (!row) return; @@ -225,6 +236,9 @@ async function expireApproval(approvalId: string, reason: string): Promise log.info('OneCLI approval expired', { approvalId, reason }); } +/** Timer-driven expiry — the audit wrapper records a system-actor rejection. */ +const expireApproval = auditOneCliExpiry(expireApprovalInner); + async function editCardExpired(row: PendingApproval, reason: string): Promise { if (!adapterRef || !row.platform_message_id || !row.channel_type || !row.platform_id) return; try { @@ -244,7 +258,7 @@ async function editCardExpired(row: PendingApproval, reason: string): Promise { +async function sweepStaleApprovalsInner(): Promise { const rows = getPendingApprovalsByAction(ONECLI_ACTION); if (rows.length === 0) return; log.info('Sweeping stale OneCLI approvals from previous process', { count: rows.length }); @@ -254,6 +268,11 @@ async function sweepStaleApprovals(): Promise { } } +/** Startup sweep — the audit wrapper records a system-actor rejection per row. */ +const sweepStaleApprovals = auditOneCliSweep(sweepStaleApprovalsInner, () => + getPendingApprovalsByAction(ONECLI_ACTION), +); + /** The hosted gateway's structured request summary — not yet in the SDK's * ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being * performed plus labeled fields (To / Subject / Body for email sends). */ diff --git a/src/modules/approvals/primitive.audit.test.ts b/src/modules/approvals/primitive.audit.test.ts new file mode 100644 index 000000000..909335672 --- /dev/null +++ b/src/modules/approvals/primitive.audit.test.ts @@ -0,0 +1,164 @@ +/** + * Pending-event coverage for the decorated requestApproval: the audit + * decorator emits exactly one `pending` event per successfully queued hold, + * naming the approval and the picked approver — and nothing when no hold + * reached an approver. + */ +import * as fs from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const appended = vi.hoisted(() => ({ lines: [] as string[] })); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-primitive-audit', AUDIT_ENABLED: true }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-primitive-audit'; + +vi.mock('../../audit/store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appendAuditLine: (line: string) => { + appended.lines.push(line); + }, + }; +}); + +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), +})); + +// No adapter: card delivery is skipped, the hold still succeeds. +vi.mock('../../delivery.js', () => ({ + getDeliveryAdapter: () => null, +})); + +// Resolve any approver to a reachable fake DM without the platform stack. +vi.mock('../permissions/user-dm.js', () => ({ + ensureUserDm: vi.fn(async () => ({ + id: 'mg-dm', + channel_type: 'telegram', + platform_id: 'dm-owner', + })), +})); + +import { closeDb, initTestDb, runMigrations } from '../../db/index.js'; +import { createAgentGroup } from '../../db/agent-groups.js'; +import { createSession, getPendingApproval } from '../../db/sessions.js'; +import { grantRole } from '../permissions/db/user-roles.js'; +import { upsertUser } from '../permissions/db/users.js'; +import { requestApproval } from './primitive.js'; +import type { Session } from '../../types.js'; + +function now(): string { + return new Date().toISOString(); +} + +let session: Session; + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + appended.lines.length = 0; + const db = initTestDb(); + runMigrations(db); + + createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() }); + session = { + id: 'sess-1', + agent_group_id: 'ag-1', + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: now(), + created_at: now(), + }; + createSession(session); +}); + +afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +function grantOwner(): void { + upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() }); + grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() }); +} + +function events(): Array> { + return appended.lines.map((l) => JSON.parse(l)); +} + +describe('decorated requestApproval', () => { + it('emits one pending event naming the approval and the picked approver', async () => { + grantOwner(); + await requestApproval({ + session, + agentName: 'Agent', + action: 'install_packages', + payload: { apt: ['jq'], npm: [], reason: 'need jq' }, + title: 'Install Packages Request', + question: 'ok?', + }); + + expect(appended.lines).toHaveLength(1); + const [event] = events(); + expect(event).toMatchObject({ + actor: { type: 'agent', id: 'ag-1' }, + origin: { transport: 'container', session_id: 'sess-1' }, + action: 'self-mod.install-packages', + outcome: 'pending', + details: { apt: ['jq'], npm: [], reason: 'need jq' }, + }); + + const approvalRef = event.resources.find((r: { type: string }) => r.type === 'approval'); + expect(approvalRef.id).toMatch(/^appr-/); + expect(event.correlation_id).toBe(approvalRef.id); + expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-1' }); + expect(event.resources).toContainEqual({ type: 'user', id: 'telegram:owner' }); + // The event references the row that was actually created. + expect(getPendingApproval(approvalRef.id)).toBeDefined(); + }); + + it('emits nothing when no approver is configured (no hold was created)', async () => { + await requestApproval({ + session, + agentName: 'Agent', + action: 'install_packages', + payload: { apt: ['jq'], npm: [] }, + title: 'Install Packages Request', + question: 'ok?', + }); + + expect(appended.lines).toHaveLength(0); + }); + + it('records shape only for the message-bearing a2a gate — never the body', async () => { + grantOwner(); + await requestApproval({ + session, + agentName: 'Agent', + action: 'a2a_message_gate', + approverUserId: 'telegram:owner', + payload: { + id: 'msg-1', + platform_id: 'ag-target', + content: JSON.stringify({ text: 'hello world', files: ['report.pdf'] }), + in_reply_to: null, + }, + title: 'Message approval', + question: 'ok?', + }); + + const [event] = events(); + expect(event.action).toBe('messages.a2a-gate'); + expect(event.details).toEqual({ to: 'ag-target', body_chars: 11, attachments: ['report.pdf'] }); + expect(JSON.stringify(event)).not.toContain('hello world'); + expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-target' }); + }); +}); diff --git a/src/modules/approvals/primitive.ts b/src/modules/approvals/primitive.ts index 2d3028a64..5a28a477c 100644 --- a/src/modules/approvals/primitive.ts +++ b/src/modules/approvals/primitive.ts @@ -21,6 +21,9 @@ * exposing just user-roles/user-dms) is more churn than it's worth. Revisit * if either module becomes genuinely optional (see REFACTOR_PLAN open q #3). */ +// Direct file import (not the audit barrel): the barrel pulls in observer.ts, +// which imports this module — the narrow path keeps init order trivial. +import { auditRequestApproval } from '../../audit/wrappers.js'; import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; import { getMessagingGroup } from '../../db/messaging-groups.js'; import { createPendingApproval, getSession } from '../../db/sessions.js'; @@ -61,6 +64,8 @@ export interface ApprovalHandlerContext { payload: Record; /** User ID of the admin who approved. Empty string if unknown. */ userId: string; + /** pending_approvals id of the approval that authorized this run. */ + approvalId: string; /** Send a system chat message to the requesting agent's session. */ notify: (text: string) => void; } @@ -214,19 +219,30 @@ export interface RequestApprovalOptions { approverUserId?: string; } +/** + * A successfully queued hold — what the audit decorator needs to emit the + * pending event (the id is minted and the approver picked in here, invisible + * to callers otherwise). Null when no hold reached an approver. + */ +export interface ApprovalHold { + approvalId: string; + /** The approver the card was actually delivered to. */ + approverUserId: string; +} + /** * Queue an approval request. Picks an approver, delivers the card to their * DM, and records the pending_approvals row. Fire-and-forget from the * caller's perspective — the admin's response kicks off the registered * approval handler for this action via the response dispatcher. */ -export async function requestApproval(opts: RequestApprovalOptions): Promise { +async function requestApprovalInner(opts: RequestApprovalOptions): Promise { const { session, action, payload, title, question, agentName, approverUserId } = opts; const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id); if (approvers.length === 0) { notifyAgent(session, `${action} failed: no owner or admin configured to approve.`); - return; + return null; } const originChannelType = session.messaging_group_id @@ -236,7 +252,7 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise + * semantics as before (the hold value is audit plumbing). + */ +export const requestApproval = auditRequestApproval(requestApprovalInner); diff --git a/src/modules/approvals/response-handler.audit.test.ts b/src/modules/approvals/response-handler.audit.test.ts new file mode 100644 index 000000000..e009fe641 --- /dev/null +++ b/src/modules/approvals/response-handler.audit.test.ts @@ -0,0 +1,202 @@ +/** + * Decision + terminal audit events across the approval resolution paths: + * approvals.decide from the resolved observer, the terminal event from the + * wrapped handler run (skipped for cli_command), and the system actor for + * sweep-finalized rejects. + */ +import * as fs from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const appended = vi.hoisted(() => ({ lines: [] as string[] })); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-response-handler-audit', AUDIT_ENABLED: true }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-response-handler-audit'; + +vi.mock('../../audit/store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appendAuditLine: (line: string) => { + appended.lines.push(line); + }, + }; +}); + +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), +})); + +import { registerAuditObserver } from '../../audit/observer.js'; +import { closeDb, initTestDb, runMigrations } from '../../db/index.js'; +import { createAgentGroup } from '../../db/agent-groups.js'; +import { createPendingApproval, createSession, getPendingApproval, getSession } from '../../db/sessions.js'; +import { grantRole } from '../permissions/db/user-roles.js'; +import { upsertUser } from '../permissions/db/users.js'; +import { finalizeReject } from './finalize.js'; +import { registerApprovalHandler } from './primitive.js'; +import { handleApprovalsResponse } from './response-handler.js'; + +registerAuditObserver(); + +function now(): string { + return new Date().toISOString(); +} + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + appended.lines.length = 0; + const db = initTestDb(); + runMigrations(db); + + createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() }); + createSession({ + id: 'sess-1', + agent_group_id: 'ag-1', + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: now(), + created_at: now(), + }); + upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() }); + grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() }); +}); + +afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +function seedApproval(id: string, action: string, payload: Record): void { + createPendingApproval({ + approval_id: id, + session_id: 'sess-1', + request_id: id, + action, + payload: JSON.stringify(payload), + created_at: now(), + title: 'Test approval', + options_json: JSON.stringify([]), + }); +} + +function click(questionId: string, value: string) { + return handleApprovalsResponse({ + questionId, + value, + userId: 'owner', + channelType: 'telegram', + platformId: 'dm-owner', + threadId: null, + }); +} + +function events(): Array> { + return appended.lines.map((l) => JSON.parse(l)); +} + +describe('approval resolution audit events', () => { + it('approve → terminal success (correlated) + approvals.decide with the human approver', async () => { + const handler = vi.fn().mockResolvedValue(undefined); + registerApprovalHandler('install_packages', handler); + seedApproval('appr-ok', 'install_packages', { apt: ['jq'], npm: [] }); + + await click('appr-ok', 'approve'); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ approvalId: 'appr-ok' })); + + const all = events(); + expect(all).toHaveLength(2); + const [terminal, decide] = all; + expect(terminal).toMatchObject({ + actor: { type: 'agent', id: 'ag-1' }, + action: 'self-mod.install-packages', + outcome: 'success', + correlation_id: 'appr-ok', + }); + expect(terminal.resources).toContainEqual({ type: 'approval', id: 'appr-ok' }); + expect(decide).toMatchObject({ + actor: { type: 'human', id: 'telegram:owner' }, + origin: { transport: 'channel', channel: 'telegram' }, + action: 'approvals.decide', + outcome: 'approved', + correlation_id: 'appr-ok', + details: { gated_action: 'self-mod.install-packages', requested_by: 'ag-1' }, + }); + }); + + it('handler failure → terminal failure with the error, decision still recorded', async () => { + registerApprovalHandler('add_mcp_server', async () => { + throw new Error('rebuild exploded'); + }); + seedApproval('appr-fail', 'add_mcp_server', { name: 'notion', command: 'npx' }); + + await click('appr-fail', 'approve'); + + const all = events(); + expect(all).toHaveLength(2); + expect(all[0]).toMatchObject({ + action: 'self-mod.add-mcp-server', + outcome: 'failure', + correlation_id: 'appr-fail', + }); + expect(all[0].details.error).toContain('rebuild exploded'); + expect(all[1]).toMatchObject({ action: 'approvals.decide', outcome: 'approved' }); + }); + + it('cli_command approve → decide only; the terminal event belongs to the dispatch middleware', async () => { + registerApprovalHandler('cli_command', vi.fn().mockResolvedValue(undefined)); + seedApproval('appr-cli', 'cli_command', { + frame: { id: '1', command: 'groups-update', args: { id: 'ag-1' } }, + callerContext: { caller: 'agent', sessionId: 'sess-1', agentGroupId: 'ag-1', messagingGroupId: 'mg-1' }, + }); + + await click('appr-cli', 'approve'); + + const all = events(); + expect(all).toHaveLength(1); + expect(all[0]).toMatchObject({ action: 'approvals.decide', outcome: 'approved', correlation_id: 'appr-cli' }); + }); + + it('reject click → approvals.decide rejected, no terminal event', async () => { + registerApprovalHandler('install_packages', vi.fn()); + seedApproval('appr-rej', 'install_packages', { apt: ['jq'], npm: [] }); + + await click('appr-rej', 'reject'); + + const all = events(); + expect(all).toHaveLength(1); + expect(all[0]).toMatchObject({ + actor: { type: 'human', id: 'telegram:owner' }, + action: 'approvals.decide', + outcome: 'rejected', + correlation_id: 'appr-rej', + }); + expect(getPendingApproval('appr-rej')).toBeUndefined(); + }); + + it('sweep-finalized reject (empty resolver id) → the system actor decides', async () => { + seedApproval('appr-ghost', 'install_packages', { apt: ['jq'], npm: [] }); + const approval = getPendingApproval('appr-ghost')!; + const session = getSession('sess-1')!; + + await finalizeReject(approval, session, '', 'approver never replied'); + + const all = events(); + expect(all).toHaveLength(1); + expect(all[0]).toMatchObject({ + actor: { type: 'system', id: 'host' }, + origin: { transport: 'channel' }, + action: 'approvals.decide', + outcome: 'rejected', + correlation_id: 'appr-ghost', + }); + }); +}); diff --git a/src/modules/approvals/response-handler.ts b/src/modules/approvals/response-handler.ts index 52fc705ef..a07ee698d 100644 --- a/src/modules/approvals/response-handler.ts +++ b/src/modules/approvals/response-handler.ts @@ -15,6 +15,7 @@ * The response handler is registered via core's `registerResponseHandler`; * core iterates handlers and the first one to return `true` claims the response. */ +import { runApprovedHandler } from '../../audit/index.js'; import { wakeContainer } from '../../container-runner.js'; import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js'; import type { ResponsePayload } from '../../response-registry.js'; @@ -42,7 +43,7 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise } if (approval.action === ONECLI_ACTION) { - if (resolveOneCLIApproval(payload.questionId, payload.value)) { + if (resolveOneCLIApproval(payload.questionId, payload.value, namespacedUserId(payload) ?? '')) { return true; } // Row exists but the in-memory resolver is gone (timer fired or the process @@ -112,7 +113,14 @@ async function handleRegisteredApproval( const payload = JSON.parse(approval.payload); try { - await handler({ session, payload, userId, notify }); + // runApprovedHandler wraps the invocation to emit the gated chain's + // terminal audit event; rethrows, so the catch below behaves as before. + await runApprovedHandler( + handler, + { session, payload, userId, approvalId: approval.approval_id, notify }, + approval, + session, + ); log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId }); } catch (err) { log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err }); diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 6a6f19b43..6d950f21c 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -27,6 +27,13 @@ import { setSenderScopeGate, type AccessGateResult, } from '../../router.js'; +import { + auditChannelDecision, + auditChannelNameInterceptor, + auditSenderDecision, + type ChannelApprovalResult, + type SenderApprovalResult, +} from '../../audit/wrappers.js'; import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { getDeliveryAdapter } from '../../delivery.js'; @@ -222,9 +229,9 @@ setSenderScopeGate( * Deny: delete the row (no "deny list" — a future message re-triggers a * fresh card per ACTION-ITEMS item 5 "no denial persistence"). */ -async function handleSenderApprovalResponse(payload: ResponsePayload): Promise { +async function handleSenderApprovalResponse(payload: ResponsePayload): Promise { const row = getPendingSenderApproval(payload.questionId); - if (!row) return false; + if (!row) return { claimed: false }; // payload.userId is the raw platform userId (e.g. "6037840640"); namespace it // with the channel type so it matches users(id) format. Some platforms @@ -243,10 +250,18 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise { * captures the reply and creates immediately) * reject — set denied_at, delete pending row */ -async function handleChannelApprovalResponse(payload: ResponsePayload): Promise { +async function handleChannelApprovalResponse(payload: ResponsePayload): Promise { const row = getPendingChannelApproval(payload.questionId); - if (!row) return false; + if (!row) return { claimed: false }; const clickerId = payload.userId ? payload.userId.includes(':') @@ -324,9 +339,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< clickerId, expectedApprover: row.approver_user_id, }); - return true; + return { claimed: true }; } const approverId = clickerId; + const decisionBase = { + messagingGroupId: row.messaging_group_id, + approverId, + channelType: payload.channelType, + }; // ── Reject / Cancel ── if (payload.value === REJECT_VALUE) { @@ -336,7 +356,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< messagingGroupId: row.messaging_group_id, approverId, }); - return true; + return { claimed: true, decision: { kind: 'rejected', ...decisionBase } }; } // ── Choose existing agent — send agent-selection follow-up card ── @@ -347,11 +367,11 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< messagingGroupId: row.messaging_group_id, approverUserId: row.approver_user_id, }); - return true; + return { claimed: true }; } const adapter = getDeliveryAdapter(); - if (!adapter) return true; + if (!adapter) return { claimed: true }; const agentGroups = getAllAgentGroups(); const options = buildAgentSelectionOptions(agentGroups, approverId); @@ -378,7 +398,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< err, }); } - return true; + return { claimed: true }; } // ── Create new agent — prompt for free-text name ── @@ -389,7 +409,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< messagingGroupId: row.messaging_group_id, approverUserId: row.approver_user_id, }); - return true; + return { claimed: true }; } const adapter = getDeliveryAdapter(); @@ -397,7 +417,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< log.error('Channel registration: no delivery adapter for name prompt', { messagingGroupId: row.messaging_group_id, }); - return true; + return { claimed: true }; } awaitingNameInput.set(row.approver_user_id, { @@ -421,7 +441,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< }); awaitingNameInput.delete(row.approver_user_id); } - return true; + return { claimed: true }; } // ── Resolve target agent group (connect to existing or create new) ── @@ -436,7 +456,10 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< targetAgentGroupId, }); deletePendingChannelApproval(row.messaging_group_id); - return true; + return { + claimed: true, + decision: { kind: 'failed', reason: 'target agent group no longer exists', ...decisionBase }, + }; } if (!hasAdminPrivilege(approverId, targetAgentGroupId)) { log.warn('Channel registration: target agent group rejected for unauthorized approver', { @@ -444,14 +467,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< targetAgentGroupId, approverId, }); - return true; + return { claimed: true }; } } else { log.warn('Channel registration: unknown response value', { messagingGroupId: row.messaging_group_id, value: payload.value, }); - return true; + return { claimed: true }; } // ── Wire + replay (shared path for connect and create) ── @@ -464,7 +487,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< err, }); deletePendingChannelApproval(row.messaging_group_id); - return true; + return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } }; } const isGroup = event.threadId !== null; @@ -512,22 +535,26 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< err, }); } - return true; + return { + claimed: true, + decision: { kind: 'connected', agentGroupId: targetAgentGroupId, createdAgentGroup: false, ...decisionBase }, + }; } -registerResponseHandler(handleChannelApprovalResponse); +registerResponseHandler(auditChannelDecision(handleChannelApprovalResponse)); // ── Free-text name interceptor ── // Captures the next DM from an approver who clicked "Create new agent", // creates the agent immediately, wires the channel, and replays. -registerMessageInterceptor(async (event: InboundEvent): Promise => { +async function channelNameInterceptor(event: InboundEvent): Promise { const userId = extractAndUpsertUser(event); - if (!userId) return false; + if (!userId) return { claimed: false }; const pending = awaitingNameInput.get(userId); - if (!pending) return false; - if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false; + if (!pending) return { claimed: false }; + if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) + return { claimed: false }; awaitingNameInput.delete(userId); @@ -541,11 +568,11 @@ registerMessageInterceptor(async (event: InboundEvent): Promise => { if (!text) { log.warn('Channel registration: empty name reply, ignoring', { userId }); - return true; + return { claimed: true }; } const row = getPendingChannelApproval(pending.channelMgId); - if (!row) return true; + if (!row) return { claimed: true }; const ag = createNewAgentGroup(text); log.info('Channel registration: new agent group created', { @@ -554,6 +581,14 @@ registerMessageInterceptor(async (event: InboundEvent): Promise => { agentName: ag.name, folder: ag.folder, }); + const decisionBase = { + messagingGroupId: row.messaging_group_id, + approverId: userId, + channelType: event.channelType, + agentGroupId: ag.id, + createdAgentGroup: true, + agentName: ag.name, + }; let originalEvent: InboundEvent; try { @@ -564,7 +599,8 @@ registerMessageInterceptor(async (event: InboundEvent): Promise => { err, }); deletePendingChannelApproval(row.messaging_group_id); - return true; + // The group WAS created above — the audit record must say so. + return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } }; } const isGroup = originalEvent.threadId !== null; @@ -628,5 +664,7 @@ registerMessageInterceptor(async (event: InboundEvent): Promise => { .catch(() => {}); } } - return true; -}); + return { claimed: true, decision: { kind: 'connected', ...decisionBase } }; +} + +registerMessageInterceptor(auditChannelNameInterceptor(channelNameInterceptor));