Compare commits

..

2 Commits

Author SHA1 Message Date
Moshe Krupper cd7515c1a5 feat(audit): post-write hook registry for in-process exporters
registerAuditHook({ name, onEvent, init?, maintain?, shutdown? }) — the
in-process extension seam for future exporters, following the tree's
observer idiom (registerApprovalResolvedHandler et al). Hooks observe
the LOG, not the event stream: onEvent(event, line) fires only after
the event was durably appended to the local day-file, so exported ⊆
written holds by construction — an external system can never know an
event the source of truth doesn't, and a hook that misses events
catches up by reading the day-files (the at-least-once story).

Failures are isolated everywhere: a throwing hook is logged with its
name and never affects the log, other hooks, or the audited action; a
failing append means hooks are not called at all. Lifecycle: init at
boot (throw = refuse to start, same posture as the writability assert),
maintain from the 60s host-sweep (now one maintainAudit() entry point
covering retention prune + hooks), shutdown via the host's graceful-
shutdown registry.

No forwarder, credentials, or transport ships in core — an exporter is
an in-tree or skill-installed module that registers itself; external
services keep the zero-code paths (tail data/audit/*.ndjson, or poll
ncl audit list --format ndjson).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:42:34 +03:00
Moshe Krupper 4896887660 feat: opt-in local audit log (AUDIT_ENABLED)
One canonical SIEM-shaped event per action, appended to NDJSON day-files
under data/audit/. v1 surfaces: every ncl command (both transports,
including scope denials) and every host-routed approval — pending,
decision, and terminal outcome — covering CLI gates, self-mod, a2a
message gates, agent creation (incl. the ungated global-scope door and
the channel-registration name flow), the permissions sender/channel
cards, and OneCLI credential holds.

Emit architecture is wrappers at module edges, zero inline audit calls:
withAudit(dispatch) middleware (trace out-param carries the resolved
command + effective args), a decorated requestApproval (owns pending
events; inner returns the minted hold), an observer on the existing
approval-resolved hook (decision events), a wrapped handler run in the
response handler (terminal events; cli_command's terminal comes from the
replay through the dispatch middleware, correlated via
DispatchOptions.approvalId), and audited() seams for permissions,
OneCLI, and performCreateAgent. Gated chains share correlation_id = the
approval id.

Governance guarantees: off by default (the emitter no-ops and data/audit
is never created); fail-open + loud on append failure; boot writability
assert when enabled (refuse to start over a silent audit gap); recursive
secret-key redaction + ~2KB value truncation at the single emit point;
message-bearing events record shape only (body_chars, attachment names).
Retention (AUDIT_RETENTION_DAYS, default 90, 0 = forever) hard-deletes
day-files at boot + once daily in the host sweep.

Read back with ncl audit list (--actor --action --resource --outcome
--since --until --correlation --limit, --format ndjson) — host + global
scope only; group-scoped agents fail closed via the existing allowlist.

Design docs (requirements + grill-session decisions) live on the team
hub; docs/SECURITY.md carries the operator-facing documentation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:20:13 +03:00
46 changed files with 1144 additions and 1593 deletions
-68
View File
@@ -1,68 +0,0 @@
# Remove /add-audit
Reverses every change apply made. The audit day-files under `data/audit/` are
the operator's records — this removes the recorder, not the recordings; delete
`data/audit/` yourself if you also want the history gone.
## 1. Delete every copied file
```bash
rm -rf src/audit
rm -f src/audit-wiring.test.ts \
src/cli/dispatch.audit.ts src/cli/dispatch.audit.test.ts \
src/cli/resources/audit.ts \
src/modules/approvals/approvals.audit.ts src/modules/approvals/approvals.audit.test.ts \
src/modules/approvals/approvals-observer.audit.ts \
src/modules/approvals/primitive.audit.test.ts src/modules/approvals/response-handler.audit.test.ts \
src/modules/permissions/permissions.audit.ts src/modules/permissions/permissions.audit.test.ts \
src/modules/agent-to-agent/create-agent.audit.ts src/modules/agent-to-agent/create-agent.audit.test.ts
```
## 2. Revert the seam compositions (DELETE lines, do not comment out)
- `src/cli/resources/index.ts`: delete the `import './audit.js';` line.
- `src/cli/dispatch.ts`: delete the `import { withAudit } from './dispatch.audit.js';`
line and the `export const dispatch = withAudit(dispatchInner);` block (with
its comment); rename `async function dispatchInner(` back to
`export async function dispatch(`.
- `src/modules/approvals/primitive.ts`: delete the
`import { auditRequestApproval } from './approvals.audit.js';` line (and its
comment) and the `export const requestApproval = auditRequestApproval(...)`
block; rename `async function requestApprovalInner(` back to
`export async function requestApproval(`.
- `src/modules/approvals/response-handler.ts`: delete the
`import { runApprovedHandler } from './approvals.audit.js';` line; replace the
`await runApprovedHandler(...)` call (and its comment) with:
`await handler({ session, payload, userId, approvalId: approval.approval_id, notify });`
- `src/modules/approvals/onecli-approvals.ts`: delete the adapter import line,
the `recordOneCliHold` const (change `recordOneCliHold({` back to
`createPendingApproval({`), and the three wrapper consts (with comments);
rename the three `...Inner` functions back (`resolveOneCLIApprovalInner`
`export function resolveOneCLIApproval`, `expireApprovalInner`
`expireApproval`, `sweepStaleApprovalsInner``sweepStaleApprovals`).
- `src/modules/permissions/index.ts`: delete the adapter import line; restore
the three plain registrations:
```ts
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
```
- `src/index.ts`: delete the audit block in `main()` (the comment, the two
`await import(...)` lines, and `initAuditLog();`).
- `src/host-sweep.ts`: delete the audit-maintenance `try { ... }` block in
`sweep()` (with its comment).
## 3. Revert config and env
- `src/config.ts`: remove `'AUDIT_ENABLED',` and `'AUDIT_RETENTION_DAYS',`
from the `readEnvFile([...])` array, and delete the
`AUDIT_ENABLED` / `AUDIT_RETENTION_DAYS` export block (with its comments).
- `.env`: remove the `AUDIT_ENABLED` and `AUDIT_RETENTION_DAYS` lines.
## 4. Verify
```bash
pnpm run build && pnpm test
```
Then restart the service.
-303
View File
@@ -1,303 +0,0 @@
---
name: add-audit
description: Add an opt-in local audit log — every ncl command (both transports, including denials), every host-routed approval (pending/decision/terminal, correlated by approval id), permissions card decisions, OneCLI credential holds, and ungated agent creation, written as SIEM-shaped append-only NDJSON day-files under data/audit/. Read back with `ncl audit list`; export exporters via registerAuditHook. Off until AUDIT_ENABLED=true.
---
# /add-audit — opt-in local audit log
Records one canonical, SIEM-shaped event per action — actor, origin, dotted
action, touched resources, outcome, approval correlation — to append-only
NDJSON day-files under `data/audit/`. Feature is **opt-in**: with
`AUDIT_ENABLED` unset nothing is persisted, the emit seam no-ops at one
boolean check, and `data/audit/` is never created.
Architecture: `src/audit/` is a **domain-free leaf** (schema, emit seam,
store, reader, post-write hooks, shared vocabulary). How each domain
describes itself lives in **domain-owned `*.audit.ts` adapter files** next to
the code they observe; each composes at its module's edge in one line.
Business logic contains zero audit calls: `grep emitAuditEvent src/` matches
only `src/audit/` and `*.audit.ts`.
The adapters compose on seams trunk already ships (`DispatchTrace`,
`ApprovalHold`, `SenderApprovalResult`/`ChannelApprovalResult`,
`CreateAgentResult`, the `userId` arg on `resolveOneCLIApproval`). If an edit
below is already present, skip it — apply is safe to re-run.
## Steps
### 1. Copy the files
```bash
cp -R .claude/skills/add-audit/add/src/. src/
```
Adds `src/audit/` (the leaf + its tests), the domain adapters
(`src/cli/dispatch.audit.ts`, `src/modules/approvals/approvals.audit.ts`,
`src/modules/approvals/approvals-observer.audit.ts`,
`src/modules/permissions/permissions.audit.ts`,
`src/modules/agent-to-agent/create-agent.audit.ts`), their tests, the
`ncl audit` resource (`src/cli/resources/audit.ts`), and
`src/audit-wiring.test.ts`. No dependency is added — the module is stdlib-only.
### 2. Register the `ncl audit` resource
Append to `src/cli/resources/index.ts`:
```ts
import './audit.js';
```
### 3. Add the two config vars to `src/config.ts`
Add both names to the `readEnvFile([...])` array:
```ts
'AUDIT_ENABLED',
'AUDIT_RETENTION_DAYS',
```
Then append after the `ONECLI_GATEWAY_CONTAINER` export block:
```ts
// Local audit log — opt-in (installed by /add-audit). 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;
```
### 4. Compose the dispatch middleware — `src/cli/dispatch.ts`
Add to the import block:
```ts
import { withAudit } from './dispatch.audit.js';
```
Rename the dispatcher (signature line only):
```ts
export async function dispatch( async function dispatchInner(
```
Insert immediately after `dispatchInner`'s closing brace (before the
`registerApprovalHandler('cli_command', ...)` call):
```ts
/**
* Public dispatcher — the audit middleware wraps the inner dispatcher, so the
* socket server, the container delivery-action, and the approved replay are
* all covered without changing a call site.
*/
export const dispatch = withAudit(dispatchInner);
```
### 5. Decorate requestApproval — `src/modules/approvals/primitive.ts`
Add to the import block:
```ts
// Sibling adapter import; it imports this module back type-only (no cycle).
import { auditRequestApproval } from './approvals.audit.js';
```
Rename the request function (signature line only):
```ts
export async function requestApproval( async function requestApprovalInner(
```
Insert immediately after `requestApprovalInner`'s closing brace:
```ts
/**
* Public export — the audit decorator wraps the inner request so every gated
* hold emits its pending event from one place. Pass-through: callers see the
* hold exactly as the inner returns it.
*/
export const requestApproval = auditRequestApproval(requestApprovalInner);
```
### 6. Wrap the approved-handler run — `src/modules/approvals/response-handler.ts`
Add to the import block:
```ts
import { runApprovedHandler } from './approvals.audit.js';
```
Replace the handler invocation inside `handleRegisteredApproval`:
```ts
await handler({ session, payload, userId, approvalId: approval.approval_id, notify });
```
with:
```ts
// 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,
);
```
### 7. Wrap the OneCLI paths — `src/modules/approvals/onecli-approvals.ts`
Add to the import block:
```ts
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from './approvals.audit.js';
```
Four compositions:
a. After the `shortApprovalId()` function, add:
```ts
/** Row insert for a hold — the audit wrapper emits the pending event from it. */
const recordOneCliHold = auditOneCliHold(createPendingApproval);
```
and inside `handleRequest`, change the row insert `createPendingApproval({`
to `recordOneCliHold({`.
b. Rename `export function resolveOneCLIApproval(` to
`function resolveOneCLIApprovalInner(` (signature line only) and insert after
its closing brace:
```ts
/**
* 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);
```
c. Rename `async function expireApproval(` to
`async function expireApprovalInner(` and insert after its closing brace:
```ts
/** Timer-driven expiry — the audit wrapper records a system-actor rejection. */
const expireApproval = auditOneCliExpiry(expireApprovalInner);
```
d. Rename `async function sweepStaleApprovals(` to
`async function sweepStaleApprovalsInner(` and insert after its closing brace:
```ts
/** Startup sweep — the audit wrapper records a system-actor rejection per row. */
const sweepStaleApprovals = auditOneCliSweep(sweepStaleApprovalsInner, () =>
getPendingApprovalsByAction(ONECLI_ACTION),
);
```
### 8. Wrap the permissions decisions — `src/modules/permissions/index.ts`
Add to the import block:
```ts
import { auditChannelDecision, auditChannelNameInterceptor, auditSenderDecision } from './permissions.audit.js';
```
Replace the three registration coercions:
```ts
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
registerResponseHandler(auditSenderDecision(handleSenderApprovalResponse));
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
registerResponseHandler(auditChannelDecision(handleChannelApprovalResponse));
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
registerMessageInterceptor(auditChannelNameInterceptor(channelNameInterceptor));
```
### 9. Boot wiring — `src/index.ts`
Insert inside `main()`, after the `migrateGroupsToClaudeLocal();` step and
before the container-runtime step:
```ts
// Audit log (optional — installed by /add-audit; AUDIT_ENABLED gates writes).
// The observer import self-registers approvals.decide; initAuditLog asserts
// data/audit/ is writable when enabled (throw → exit 1) and starts hooks.
await import('./modules/approvals/approvals-observer.audit.js');
const { initAuditLog } = await import('./audit/index.js');
initAuditLog();
```
### 10. Sweep wiring — `src/host-sweep.ts`
Insert inside `sweep()`, immediately before the `setTimeout(sweep, SWEEP_INTERVAL_MS);` reschedule:
```ts
// Audit maintenance (installed by /add-audit) — retention prune (throttled
// to once per UTC day inside the module) + post-write hooks' maintain().
try {
const { maintainAudit } = await import('./audit/index.js');
maintainAudit();
} catch (err) {
log.error('Audit maintenance failed', { err });
}
```
### 11. Enable it
Append to `.env` (this is the point of installing the skill — but the switch
stays yours):
```bash
AUDIT_ENABLED=true
# Optional; default 90. 0 = keep forever.
#AUDIT_RETENTION_DAYS=90
```
### 12. Verify
```bash
pnpm run build
pnpm exec vitest run src/audit src/audit-wiring.test.ts src/cli/dispatch.audit.test.ts \
src/modules/approvals/primitive.audit.test.ts src/modules/approvals/response-handler.audit.test.ts \
src/modules/approvals/approvals.audit.test.ts src/modules/permissions/permissions.audit.test.ts \
src/modules/agent-to-agent/create-agent.audit.test.ts
pnpm test # full suite — the composed system must stay green
```
Then restart the service (macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
Linux: `systemctl --user restart nanoclaw`).
## Usage
```bash
ncl audit list # newest first, default limit 100
ncl audit list --actor host:moshe --since 7d
ncl audit list --outcome denied --since 90d --format ndjson # SIEM export
ncl audit list --correlation appr-... # everything on one gated chain
```
Host socket + `cli_scope: global` agents only; group-scoped agents never see
the resource (audit spans groups — exclusion fails closed). On a disabled box
the command errors with "audit log is disabled" rather than returning an
empty list that would read as history.
Exporters: call `registerAuditHook(...)` (from `src/audit/index.js`) in a
module imported at boot — `onEvent` fires only after a successful local
append, so an exporter can never know an event the source of truth doesn't.
## Semantics worth knowing
- **Fail-open + loud**: a failed append is `log.error`'d and the action
proceeds; at boot (enabled only) the host refuses to start if `data/audit/`
isn't writable.
- **Append-only**: nothing updates a line; retention prune unlinks whole
day-files past `AUDIT_RETENTION_DAYS` (a literal hard delete).
- **No message bodies**: message-bearing events record shape only
(`body_chars`, attachment names); a recursive key-pattern redactor masks
secret-looking values at the emit seam.
@@ -1,104 +0,0 @@
/**
* Wiring test for the /add-audit skill's two boot-path code edits.
*
* The skill inserts one colocated block into src/index.ts (dynamic import of
* the self-registering approvals observer + `initAuditLog()`), and one into
* src/host-sweep.ts (`maintainAudit()` inside the sweep, fail-isolated). A
* behavioral test can't see whether those edits are present and correctly
* placed — booting the real host is too heavy — so this asserts them
* structurally, via the TypeScript AST:
* - the observer module is dynamically imported by its correct path and
* initAuditLog() runs after it (registration before enablement),
* - both are DIRECT statements of main()'s body, after DB init and before
* the boot-complete log,
* - maintainAudit() is called inside sweep() before the reschedule.
*
* Delete or misplace an edit and this goes red. The seam wrappers themselves
* are covered behaviorally (dispatch.audit.test.ts and friends).
*
* Ships with the skill; apply copies it to src/.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
function bodyOf(file: string, fnName: string): { stmts: ts.NodeArray<ts.Statement>; sf: ts.SourceFile } {
const source = fs.readFileSync(path.resolve(process.cwd(), file), 'utf8');
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
let body: ts.NodeArray<ts.Statement> | undefined;
sf.forEachChild((n) => {
if (ts.isFunctionDeclaration(n) && n.name?.text === fnName && n.body) {
body = n.body.statements;
}
});
if (!body) throw new Error(`${fnName}() not found in ${file}`);
return { stmts: body, sf };
}
/** `await import('<path>')` as a bare expression statement. */
function isAwaitedImportStatement(s: ts.Statement, importPath: string): boolean {
return (
ts.isExpressionStatement(s) &&
ts.isAwaitExpression(s.expression) &&
ts.isCallExpression(s.expression.expression) &&
s.expression.expression.expression.kind === ts.SyntaxKind.ImportKeyword &&
ts.isStringLiteral(s.expression.expression.arguments[0]!) &&
(s.expression.expression.arguments[0] as ts.StringLiteral).text === importPath
);
}
/** `const { ... } = await import('<path>')` as a statement. */
function isDynamicImportBinding(s: ts.Statement, importPath: string): boolean {
if (!ts.isVariableStatement(s)) return false;
const init = s.declarationList.declarations[0]?.initializer;
if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false;
const call = init.expression;
if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
const arg = call.arguments[0];
return !!arg && ts.isStringLiteral(arg) && arg.text === importPath;
}
function isBareCall(s: ts.Statement, callee: string): boolean {
return (
ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
ts.isIdentifier(s.expression.expression) &&
s.expression.expression.text === callee
);
}
describe('/add-audit wiring in src/index.ts', () => {
it('imports the self-registering observer, then initAuditLog(), colocated in main() after DB init and before the boot-complete log', () => {
const { stmts, sf } = bodyOf('src/index.ts', 'main');
const observerIdx = stmts.findIndex((s) =>
isAwaitedImportStatement(s, './modules/approvals/approvals-observer.audit.js'),
);
const importIdx = stmts.findIndex((s) => isDynamicImportBinding(s, './audit/index.js'));
const callIdx = stmts.findIndex((s) => isBareCall(s, 'initAuditLog'));
const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations('));
const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')"));
expect(observerIdx, 'the observer must be dynamically imported in main()').toBeGreaterThanOrEqual(0);
expect(importIdx, "dynamic import('./audit/index.js') must be a statement of main()").toBeGreaterThanOrEqual(0);
expect(callIdx, 'initAuditLog() must be a statement of main()').toBeGreaterThanOrEqual(0);
expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0);
expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0);
expect(observerIdx, 'the observer import must come after DB init').toBeGreaterThan(migrateIdx);
expect(importIdx, 'the audit import must come after the observer import').toBeGreaterThan(observerIdx);
expect(callIdx, 'initAuditLog() must come after its import (colocated)').toBeGreaterThan(importIdx);
expect(callIdx, 'initAuditLog() must run before the boot-complete log').toBeLessThan(runningIdx);
});
});
describe('/add-audit wiring in src/host-sweep.ts', () => {
it('calls maintainAudit() inside sweep(), fail-isolated, before the reschedule', () => {
const { stmts, sf } = bodyOf('src/host-sweep.ts', 'sweep');
const auditIdx = stmts.findIndex((s) => ts.isTryStatement(s) && s.getText(sf).includes('maintainAudit()'));
const rescheduleIdx = stmts.findIndex((s) => s.getText(sf).includes('setTimeout(sweep'));
expect(auditIdx, 'a try-wrapped maintainAudit() must be a statement of sweep()').toBeGreaterThanOrEqual(0);
expect(rescheduleIdx, 'setTimeout(sweep) anchor not found').toBeGreaterThanOrEqual(0);
expect(auditIdx, 'maintainAudit() must run before the sweep reschedules itself').toBeLessThan(rescheduleIdx);
});
});
@@ -1,17 +0,0 @@
/**
* Opt-in local audit log — installed by /add-audit.
*
* This directory is a domain-free leaf: the event schema, the emit seam, the
* store, the reader, post-write hooks, and shared vocabulary. What gets
* audited — and how each domain describes itself — lives in the domain-owned
* `*.audit.ts` adapter files next to the code they observe. Business logic
* contains zero audit calls.
*
* emitAuditEvent is deliberately NOT re-exported here: adapters import it
* from './emit.js' directly, and only src/audit/ + `*.audit.ts` may call it.
*/
export * from './types.js';
export { redactDetails } from './redact.js';
export { AUDIT_DIR } from './store.js';
export { initAuditLog, maintainAudit } from './init.js';
export { type AuditHook, registerAuditHook } from './hooks.js';
@@ -1,55 +0,0 @@
/**
* Domain-free event vocabulary — actor and origin constructors shared by the
* domain-owned `*.audit.ts` adapters. Pure derivation; nothing here writes
* the log.
*
* Leaf rule: this module (like the rest of src/audit/) may depend on node,
* config/log, shared types, and the db read layer — never on src/cli/* or
* src/modules/*. Domain-specific mapping (CLI resources, approval payloads,
* OneCLI rows) lives in the adapter file of the domain that owns it.
*/
import os from 'os';
import { getMessagingGroup } from '../db/messaging-groups.js';
import type { Session } from '../types.js';
import { type AuditActor, type AuditOrigin, SYSTEM_ACTOR } from './types.js';
/**
* Host callers stamp `host:<install user>` 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 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';
}
}
/** Empty resolver id (sweep/timer paths) → the system actor. */
export function humanOrSystemActor(namespacedUserId: string): AuditActor {
return namespacedUserId ? { type: 'human', id: namespacedUserId } : SYSTEM_ACTOR;
}
export function originForSession(session: Session): AuditOrigin {
return containerOrigin(session.id, session.messaging_group_id);
}
export 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' };
}
@@ -1,123 +0,0 @@
/**
* CLI audit adapter (installed by /add-audit) — owns how the dispatcher
* describes itself to the audit log: the dispatch middleware plus the
* CLI-specific actor/origin/resource mapping. Composed in dispatch.ts as
* `export const dispatch = withAudit(dispatchInner)`; business logic there
* contains zero audit calls.
*/
import { emitAuditEvent } from '../audit/emit.js';
import { hostUser } from '../audit/vocab.js';
import { containerOrigin } from '../audit/vocab.js';
import { type AuditActor, type AuditOrigin, type AuditOutcome, type AuditResource } from '../audit/types.js';
import { getResource } from './crud.js';
import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js';
import type { CommandDef } from './registry.js';
// Type-only import from the module this adapter wraps — erased at runtime,
// so dispatch.ts importing this file back is not a cycle.
import type { DispatchOptions, DispatchTrace } from './dispatch.js';
// ── CLI mapping ──
/**
* Host callers stamp `host:<install user>` daemon-side (the ncl socket is
* 0600 and owned by the install user); container callers are their agent group.
*/
export function actorForCaller(ctx: CallerContext): AuditActor {
return ctx.caller === 'host' ? { type: 'human', id: `host:${hostUser()}` } : { type: 'agent', id: ctx.agentGroupId };
}
export function originForCaller(ctx: CallerContext): AuditOrigin {
if (ctx.caller === 'host') return { transport: 'socket' };
return containerOrigin(ctx.sessionId, ctx.messagingGroupId || null);
}
/**
* 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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, string> = {
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<string, unknown>): 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;
}
// ── The dispatch middleware ──
type DispatchInner = (req: RequestFrame, ctx: CallerContext, opts?: DispatchOptions) => Promise<ResponseFrame>;
/**
* 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<string, unknown> = { ...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;
};
}
@@ -1,86 +0,0 @@
/**
* agents.create adapter for the ungated door. The inner is a stub — the
* contract is "call through, emit success/failure naming parent and child".
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
vi.mock('../../audit/store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../audit/store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('../../db/messaging-groups.js', () => ({
getMessagingGroup: () => undefined,
}));
import type { Session } from '../../types.js';
import { auditCreateAgentDirect } from './create-agent.audit.js';
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',
};
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
appended.lines.length = 0;
});
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' } });
});
});
@@ -1,51 +0,0 @@
/**
* Agent-creation audit adapter (installed by /add-audit) — agents.create for
* the ungated door. 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.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import type { AuditResource } from '../../audit/types.js';
import { originForSession } from '../../audit/vocab.js';
import type { AgentGroup, Session } from '../../types.js';
// Type-only import from the module this adapter wraps — erased at runtime,
// so create-agent.ts importing this file back is not a cycle.
import type { CreateAgentResult } from './create-agent.js';
type PerformCreateAgentFn = (
name: string,
instructions: string | null,
session: Session,
sourceGroup: AgentGroup,
notify: (text: string) => void,
) => Promise<CreateAgentResult>;
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<string, unknown> = {
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;
};
}
@@ -1,127 +0,0 @@
/**
* OneCLI credential wrappers: hold + the three resolution paths. Inners are
* stubs — each wrapper's contract is "call through, emit the right event
* (or none)". The requestApproval decorator and runApprovedHandler are
* covered end-to-end in primitive.audit.test.ts / response-handler.audit.test.ts.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
const db = vi.hoisted(() => ({ row: undefined as Record<string, unknown> | undefined }));
vi.mock('../../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
vi.mock('../../audit/store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../audit/store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('../../db/sessions.js', () => ({
getPendingApproval: () => db.row,
}));
vi.mock('../../db/messaging-groups.js', () => ({
getMessagingGroup: () => undefined,
}));
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from './approvals.audit.js';
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<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
appended.lines.length = 0;
db.row = undefined;
});
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);
});
});
@@ -1,345 +0,0 @@
/**
* Approvals audit adapter (installed by /add-audit) — owns how the approvals
* domain describes itself to the audit log: the requestApproval decorator
* (every gated hold → pending event), the post-approve handler runner (the
* gated chain's terminal event), the OneCLI credential wrappers (hold + three
* resolution paths), and the approval-payload mapping they share. Composed at
* this module's edges; business logic contains zero audit calls.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import {
type AuditActor,
type AuditEventInput,
type AuditOrigin,
type AuditOutcome,
type AuditResource,
SYSTEM_ACTOR,
} from '../../audit/types.js';
import { channelOriginForUser, humanOrSystemActor, originForSession } from '../../audit/vocab.js';
import { resourcesForCli } from '../../cli/dispatch.audit.js';
import type { RequestFrame } from '../../cli/frame.js';
import { lookup } from '../../cli/registry.js';
import { getPendingApproval } from '../../db/sessions.js';
import type { PendingApproval, Session } from '../../types.js';
// Type-only imports from the module this adapter wraps — erased at runtime,
// so primitive.ts importing this file back is not a cycle.
import type { ApprovalHandler, ApprovalHandlerContext, ApprovalHold, RequestApprovalOptions } from './primitive.js';
// ── Approval-payload mapping ──
/** Dotted audit action per approval type; cli_command derives from its frame. */
export const APPROVAL_AUDIT_ACTIONS: Record<string, string> = {
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, unknown>): 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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
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;
}
/** 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: [] };
}
}
// ── requestApproval decorator ──
type RequestApprovalFn = (opts: RequestApprovalOptions) => Promise<ApprovalHold | null>;
/**
* 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. Pass-through: callers see
* the hold exactly as the inner returns it. No hold → no event.
*/
export function auditRequestApproval(inner: RequestApprovalFn): RequestApprovalFn {
return async (opts) => {
const hold = await inner(opts);
if (!hold) return hold;
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),
}));
return hold;
};
}
// ── 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<void> {
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');
}
// ── OneCLI credential approvals (hold + three resolution paths) ──
/** = 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<string, unknown> {
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<string, unknown> = {
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,
};
}
/** Wraps the hold's row insert — emits the pending event derived from the row. */
export function auditOneCliHold<T extends OneCliRowShape>(inner: (row: T) => void): (row: T) => void {
return (row) => {
inner(row);
emitAuditEvent(() => oneCliHoldEvent(row));
};
}
/**
* Wraps the card-click resolver. The row is pre-read because the inner
* deletes it on resolution; `userId` is the resolving admin (already part of
* the resolver's contract — empty means a timer/sweep, i.e. the system).
*/
export function auditOneCliDecision(
inner: (approvalId: string, selectedOption: string, userId: string) => boolean,
): (approvalId: string, selectedOption: string, userId: string) => boolean {
return (approvalId, selectedOption, userId) => {
const row = getPendingApproval(approvalId);
const resolved = inner(approvalId, selectedOption, userId);
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<void>,
): (approvalId: string, reason: string) => Promise<void> {
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<void>, listRows: () => PendingApproval[]): () => Promise<void> {
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',
}),
);
}
};
}
@@ -1,143 +0,0 @@
/**
* Permissions audit adapters: card decisions and the name interceptor.
* Inners are stubs — each wrapper's contract is "call through, emit the
* right event (or none), coerce back to claimed".
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
vi.mock('../../audit/store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../audit/store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { auditChannelDecision, auditChannelNameInterceptor, auditSenderDecision } from './permissions.audit.js';
const PAYLOAD = {
questionId: 'q1',
value: 'approve',
userId: 'U1',
channelType: 'slack',
platformId: 'p',
threadId: null,
};
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
appended.lines.length = 0;
});
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' });
});
});
@@ -1,80 +0,0 @@
/**
* Permissions audit adapter (installed by /add-audit) — senders.allow and
* channels.register events for the card/interceptor stack that never touches
* the approvals primitive. The inner handlers return domain decision
* descriptors (SenderApprovalResult / ChannelApprovalResult, defined with the
* handlers); these adapters emit the event and coerce back to the boolean the
* response registry / router expect.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import type { AuditResource } from '../../audit/types.js';
import type { InboundEvent } from '../../channels/adapter.js';
import type { ResponsePayload } from '../../response-registry.js';
// Type-only import from the module this adapter wraps — erased at runtime,
// so permissions/index.ts importing this file back is not a cycle.
import type { ChannelApprovalResult, ChannelDecision, SenderApprovalResult } from './index.js';
export function auditSenderDecision(
inner: (payload: ResponsePayload) => Promise<SenderApprovalResult>,
): (payload: ResponsePayload) => Promise<boolean> {
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;
};
}
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<string, unknown> = {};
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<ChannelApprovalResult>,
): (payload: ResponsePayload) => Promise<boolean> {
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<ChannelApprovalResult>,
): (event: InboundEvent) => Promise<boolean> {
return async (event) => {
const result = await inner(event);
if (result.decision) emitChannelDecision(result.decision);
return result.claimed;
};
}
+1
View File
@@ -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. In-process consumers (future exporters) plug in via `registerAuditHook` — post-write hooks with an init/maintain/shutdown lifecycle that fire only after the local append succeeds, so external systems can never be ahead of the source of truth; no forwarder, credentials, or transport ships in core. 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-<channel>` 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-<channel>` 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 `<message>` 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.
+2
View File
@@ -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, and `registerAuditHook` — post-write hooks for in-process exporters (fire only after a successful local append). 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).
+55
View File
@@ -169,6 +169,61 @@ 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/<UTC-day>.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.
**Integration surfaces** (no push forwarder ships in core — credentials and
transport for external systems never live here):
1. **Tail the store** — any external agent (Vector, Filebeat, Fluent Bit, a
custom daemon) tails `data/audit/*.ndjson`; the format is stable and
`schema_version`-stamped.
2. **Pull via the CLI** — poll `ncl audit list --format ndjson --since …` and
dedupe on `event_id`.
3. **In-process post-write hooks** — a module (in-tree or skill-installed)
calls `registerAuditHook({ name, onEvent, init?, maintain?, shutdown? })`
from `src/audit/`. Hooks fire only **after** an event is durably appended
to the local day-file, so anything exported is guaranteed to exist in the
source of truth; a hook that misses events catches up by reading the
day-files (at-least-once). Hook failures are isolated and logged — they
never affect the log, other hooks, or the audited action.
## Resource Limits
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
@@ -1,9 +1,9 @@
/**
* emitAuditEvent the single opt-in check and the single append point.
*
* Only src/audit/ and the domain-owned `*.audit.ts` adapter files may call
* this (adapters compose it at their module's edge; business logic never
* does): `grep emitAuditEvent src/` outside those files must stay empty.
* 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';
+13
View File
@@ -0,0 +1,13 @@
/**
* 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 } from './store.js';
export { initAuditLog, maintainAudit } from './init.js';
export { type AuditHook, registerAuditHook } from './hooks.js';
export { runApprovedHandler, withAudit } from './wrappers.js';
@@ -1,9 +1,9 @@
/**
* Boot-time audit wiring composed in src/index.ts alongside the import of
* the domain observer (`approvals-observer.audit.ts`, which self-registers).
* Boot-time audit wiring one composition line in src/index.ts.
*
* When enabled: assert data/audit/ is writable (refusing to start beats
* running with a silent audit gap), run the boot prune, and start the
* 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), run the boot prune, and start the
* registered post-write hooks' lifecycle (init here, maintain via the host
* sweep, shutdown via the host's graceful-shutdown registry).
*/
@@ -11,9 +11,11 @@ import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from '../config.js';
import { log } from '../log.js';
import { onShutdown } from '../response-registry.js';
import { initAuditHooks, maintainAuditHooks, shutdownAuditHooks } from './hooks.js';
import { registerAuditObserver } from './observer.js';
import { assertAuditWritable, AUDIT_DIR, markPrunedToday, pruneAuditLog, pruneAuditLogIfDue } from './store.js';
export function initAuditLog(): void {
registerAuditObserver();
if (!AUDIT_ENABLED) return;
try {
assertAuditWritable();
+285
View File
@@ -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:<install user>` 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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, string> = {
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<string, unknown>): 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<string, string> = {
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, unknown>): 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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
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<string, unknown> {
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<string, unknown> = {
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: [] };
}
}
@@ -1,18 +1,13 @@
/**
* Decision events (installed by /add-audit) 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 in approvals.audit.ts.
*
* Self-registers on import (the tree's observer idiom) imported once from
* the audit block in src/index.ts.
* 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.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import { channelOriginForUser, humanOrSystemActor } from '../../audit/vocab.js';
// Direct file import (not the approvals barrel) to keep the graph tight.
import { type ApprovalResolvedEvent, registerApprovalResolvedHandler } from './primitive.js';
import { auditActionForApproval, resourcesForApproval } from './approvals.audit.js';
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(() => {
@@ -38,6 +33,10 @@ export function onApprovalResolved(event: ApprovalResolvedEvent): void {
});
}
export function registerAuditObserver(): void {
registerApprovalResolvedHandler(onApprovalResolved);
}
function safeParse(json: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(json);
@@ -47,5 +46,3 @@ function safeParse(json: string): Record<string, unknown> {
return {};
}
}
registerApprovalResolvedHandler(onApprovalResolved);
+287
View File
@@ -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<string, unknown> | undefined }));
vi.mock('../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
vi.mock('./store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
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<Record<string, any>> {
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);
});
});
+365
View File
@@ -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<ResponseFrame>;
/**
* 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<string, unknown> = { ...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<ApprovalHold | null>;
/**
* 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<void> {
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<void> {
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<SenderApprovalResult>,
): (payload: ResponsePayload) => Promise<boolean> {
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<string, unknown> = {};
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<ChannelApprovalResult>,
): (payload: ResponsePayload) => Promise<boolean> {
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<ChannelApprovalResult>,
): (event: InboundEvent) => Promise<boolean> {
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<CreateAgentResult>;
/**
* 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<string, unknown> = {
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<T extends OneCliHoldRow>(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<void>,
): (approvalId: string, reason: string) => Promise<void> {
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<void>, listRows: () => PendingApproval[]): () => Promise<void> {
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',
}),
);
}
};
}
+16 -9
View File
@@ -6,6 +6,7 @@
* 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';
@@ -15,10 +16,9 @@ import { getResource } from './crud.js';
import { type CommandDef, lookup } from './registry.js';
/**
* Resolution out-param for middleware that wraps dispatch: dispatch reassigns
* `req` internally (dash-joined id fallback, group-scope auto-fill), so a
* wrapper can't see the resolved command or effective args on the frame it
* passed in.
* 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;
@@ -29,13 +29,13 @@ export type DispatchTrace = {
export type DispatchOptions = {
/** True when a command is being replayed after approval. */
approved?: boolean;
/** Id of the approval that authorized this replay — correlation for middleware/observers. */
/** Approval id when replaying — becomes the terminal audit event's correlation_id. */
approvalId?: string;
/** Filled by dispatch with the resolved command + effective args. */
/** Filled by dispatch for the audit middleware. */
trace?: DispatchTrace;
};
export async function dispatch(
async function dispatchInner(
req: RequestFrame,
ctx: CallerContext,
opts: DispatchOptions = {},
@@ -215,11 +215,18 @@ export async function dispatch(
}
}
/**
* 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' };
// approvalId rides opts so middleware/observers around dispatch can
// correlate the replay with the approval that authorized it.
// 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) {
+1 -2
View File
@@ -32,8 +32,7 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
*/
generic?: 'list' | 'get';
/**
* Canonical dotted action name, e.g. `groups.config.add-mcp-server` for
* middleware and observability surfaces that need namespaced verbs. Stamped
* 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 dashesdots.
*/
+1
View File
@@ -14,3 +14,4 @@ import './user-dms.js';
import './dropped-messages.js';
import './approvals.js';
import './sessions.js';
import './audit.js';
+10
View File
@@ -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 {
+10
View File
@@ -43,6 +43,7 @@ import {
syncProcessingAcks,
type ContainerState,
} from './db/session-db.js';
import { maintainAudit } 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,15 @@ async function sweep(): Promise<void> {
}
// MODULE-HOOK:approvals-reason-sweep:end
// Audit maintenance — retention prune (throttled to once per UTC day inside
// the module) + registered post-write hooks' maintain(). No-op when audit
// is disabled.
try {
maintainAudit();
} catch (err) {
log.error('Audit maintenance failed', { err });
}
setTimeout(sweep, SWEEP_INTERVAL_MS);
}
+5
View File
@@ -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<void> {
// 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();
+10 -2
View File
@@ -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<string, unknown>, 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,7 +113,7 @@ export const applyCreateAgent: ApprovalHandler = async ({ session, payload, noti
await performCreateAgent(name, instructions, session, sourceGroup, notify);
};
/** What creation did — the created group's ids on success, the reason on failure. */
/** 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 };
@@ -215,3 +216,10 @@ async function performCreateAgent(
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);
+25 -10
View File
@@ -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,12 +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.
* `userId` is the namespaced id of the clicking admin the resolution's
* identity, recorded here (empty when the resolver is a timer/sweep).
*/
export function resolveOneCLIApproval(approvalId: string, selectedOption: string, userId: 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,10 +81,18 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string
deletePendingApproval(approvalId);
state.resolve(decision);
log.info('OneCLI approval resolved', { approvalId, decision, decidedBy: userId || 'system' });
log.info('OneCLI approval resolved', { approvalId, decision });
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;
@@ -174,7 +181,7 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
return 'deny';
}
createPendingApproval({
recordOneCliHold({
approval_id: approvalId,
session_id: null,
request_id: request.id,
@@ -218,7 +225,7 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
});
}
async function expireApproval(approvalId: string, reason: string): Promise<void> {
async function expireApprovalInner(approvalId: string, reason: string): Promise<void> {
const rows = getPendingApprovalsByAction(ONECLI_ACTION).filter((r) => r.approval_id === approvalId);
const row = rows[0];
if (!row) return;
@@ -229,6 +236,9 @@ async function expireApproval(approvalId: string, reason: string): Promise<void>
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<void> {
if (!adapterRef || !row.platform_message_id || !row.channel_type || !row.platform_id) return;
try {
@@ -248,7 +258,7 @@ async function editCardExpired(row: PendingApproval, reason: string): Promise<vo
}
}
async function sweepStaleApprovals(): Promise<void> {
async function sweepStaleApprovalsInner(): Promise<void> {
const rows = getPendingApprovalsByAction(ONECLI_ACTION);
if (rows.length === 0) return;
log.info('Sweeping stale OneCLI approvals from previous process', { count: rows.length });
@@ -258,6 +268,11 @@ async function sweepStaleApprovals(): Promise<void> {
}
}
/** 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). */
+16 -7
View File
@@ -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';
@@ -217,9 +220,9 @@ export interface RequestApprovalOptions {
}
/**
* A successfully queued hold: the minted approval id and the approver the
* card was actually delivered to both decided in here and otherwise
* invisible to callers. Null when no hold reached an approver.
* 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;
@@ -230,11 +233,10 @@ export interface ApprovalHold {
/**
* 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 returned hold is informational) — the admin's
* response kicks off the registered approval handler for this action via the
* response dispatcher.
* 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<ApprovalHold | null> {
async function requestApprovalInner(opts: RequestApprovalOptions): Promise<ApprovalHold | null> {
const { session, action, payload, title, question, agentName, approverUserId } = opts;
const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id);
@@ -293,3 +295,10 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<App
log.info('Approval requested', { action, approvalId, agentName, approver: target.userId });
return { approvalId, approverUserId: target.userId };
}
/**
* Public export the audit decorator wraps the inner request so every gated
* hold emits its pending event from one place. Callers see Promise<void>
* semantics as before (the hold value is audit plumbing).
*/
export const requestApproval = auditRequestApproval(requestApprovalInner);
@@ -30,8 +30,7 @@ vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
// Self-registers the approvals.decide observer on import (the composed wiring).
import './approvals-observer.audit.js';
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';
@@ -41,6 +40,7 @@ import { finalizeReject } from './finalize.js';
import { registerApprovalHandler } from './primitive.js';
import { handleApprovalsResponse } from './response-handler.js';
registerAuditObserver();
function now(): string {
return new Date().toISOString();
+9 -1
View File
@@ -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';
@@ -112,7 +113,14 @@ async function handleRegisteredApproval(
const payload = JSON.parse(approval.payload);
try {
await handler({ session, payload, userId, approvalId: approval.approval_id, 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 });
+11 -37
View File
@@ -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';
@@ -208,22 +215,6 @@ setSenderScopeGate(
},
);
/**
* What a sender-approval card click decided who was allowed/denied into
* which group, and which admin decided. The response registry consumes only
* `claimed`; the decision descriptor is for observers composed around the
* handler (and for tests).
*/
export interface SenderDecision {
approved: boolean;
senderIdentity: string;
agentGroupId: string;
messagingGroupId: string;
approverId: string;
channelType: string;
}
export type SenderApprovalResult = { claimed: boolean; decision?: SenderDecision };
/**
* Response handler for the unknown-sender approval card.
*
@@ -309,7 +300,7 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<S
return { claimed: true, decision };
}
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
registerResponseHandler(auditSenderDecision(handleSenderApprovalResponse));
// ── Unknown-channel registration flow ──
@@ -317,23 +308,6 @@ setChannelRequestGate(async (mg, event) => {
await requestChannelApproval({ messagingGroupId: mg.id, event });
});
/**
* What a channel-registration flow decided: connected (to which agent group,
* created or existing), rejected, or failed and which admin drove it. Like
* SenderDecision, the registry consumes only `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 };
/**
* Response handler for the unknown-channel registration card.
*
@@ -567,7 +541,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
};
}
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
registerResponseHandler(auditChannelDecision(handleChannelApprovalResponse));
// ── Free-text name interceptor ──
// Captures the next DM from an approver who clicked "Create new agent",
@@ -625,7 +599,7 @@ async function channelNameInterceptor(event: InboundEvent): Promise<ChannelAppro
err,
});
deletePendingChannelApproval(row.messaging_group_id);
// The group WAS created above — the decision descriptor must say so.
// The group WAS created above — the audit record must say so.
return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } };
}
@@ -693,4 +667,4 @@ async function channelNameInterceptor(event: InboundEvent): Promise<ChannelAppro
return { claimed: true, decision: { kind: 'connected', ...decisionBase } };
}
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
registerMessageInterceptor(auditChannelNameInterceptor(channelNameInterceptor));