Compare commits

..

11 Commits

Author SHA1 Message Date
Moshe Krupper 26c17c0bba feat: /add-audit skill — opt-in local audit log as an install skill
The audit feature as a utility skill instead of trunk code, in the B-prime
shape: src/audit/ is a domain-free leaf (schema, emit seam, NDJSON day-file
store, reader, post-write hooks, shared vocabulary); how each domain
describes itself lives in domain-owned *.audit.ts adapters shipped next to
the code they observe (dispatch middleware, requestApproval decorator,
approval-resolved observer, OneCLI wrappers, permissions card decisions,
ungated create-agent). Apply composes each adapter at its module edge in
one line, on the seam contracts the previous commit landed.

Payload: 26 files under add/ mirroring target paths — the leaf + adapters,
their unit tests, the composed-system integration tests (dispatch,
requestApproval, response-handler chains), an AST wiring test for the two
boot-path edits (add-dashboard pattern), and the ncl audit resource.
Zero new dependencies. REMOVE.md reverses every change.

Verified: unapplied tree builds + passes 595 tests; a worktree with
SKILL.md's steps applied mechanically builds + passes 664, and
grep emitAuditEvent outside src/audit/ + *.audit.ts is empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:08:05 +03:00
Moshe Krupper d413b9a839 refactor: richer results + context threading at the dispatch/approval seams
Standalone seam contracts, no consumer shipped in trunk:
- CommandDef.action: canonical dotted action name, stamped by
  registerResource (verb-boundary-aware), defaulted from name otherwise
- dispatch: DispatchTrace out-param (resolved cmd + effective args) and
  approvalId on DispatchOptions for replay correlation
- requestApproval returns ApprovalHold (minted id + picked approver);
  ApprovalHandlerContext carries approvalId
- resolveOneCLIApproval takes the resolving admin's userId (logged)
- permissions response handlers return SenderApprovalResult /
  ChannelApprovalResult decision descriptors; registry coercions keep the
  claimed-boolean contract
- performCreateAgent returns CreateAgentResult

These are the integration points /add-audit composes on (next commit) —
middleware/observers wrap here without reaching into function bodies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:57:02 +03:00
glifocat b6cb53e21c Merge pull request #2953 from nanocoai/docs-staleness-fixes
docs: correct stale mount topology row + removed env var
2026-07-04 23:06:27 +02:00
gavrielc 5a7e75f854 Apply suggestion from @gavrielc 2026-07-04 23:36:05 +03:00
glifocat d770e56596 docs: correct stale mount topology + removed env var vs code 2026-07-04 19:39:13 +00:00
github-actions[bot] 08a1ac9753 chore: bump version to 2.1.38 2026-07-04 16:58:45 +00:00
gavrielc 273489badf Merge pull request #2931 from nanocoai/cleanup/async-image-build
Build agent images asynchronously instead of blocking the host
2026-07-04 19:58:34 +03:00
gavrielc 504651633f Merge branch 'main' into cleanup/async-image-build 2026-07-04 19:58:25 +03:00
github-actions[bot] 694ab74aa1 chore: bump version to 2.1.37 2026-07-04 16:55:14 +00:00
gavrielc aae81321e9 Merge pull request #2948 from nanocoai/cleanup/arch-scheduling-provider-docs
Fix stale architecture, scheduling, provider-config, and overlay docs
2026-07-04 19:55:02 +03:00
gavrielc e3b2ffce36 Build agent images asynchronously instead of blocking the host
Replace the execSync docker build in buildAgentGroupImage with an awaited
promisified exec so the single-threaded host stays responsive during the
image build (up to 15 minutes) that a package-install approval or
`ncl groups restart --rebuild` triggers. Timeout, buffered stdio, and
non-zero-exit error propagation are preserved; both callers already await.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:56 +03:00
41 changed files with 3533 additions and 57 deletions
+68
View File
@@ -0,0 +1,68 @@
# 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
@@ -0,0 +1,303 @@
---
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.
@@ -0,0 +1,104 @@
/**
* 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);
});
});
@@ -0,0 +1,49 @@
/**
* 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.
*/
import { randomUUID } from 'crypto';
import { AUDIT_ENABLED } from '../config.js';
import { log } from '../log.js';
import { notifyAuditHooks } from './hooks.js';
import { redactDetails } from './redact.js';
import { appendAuditLine } from './store.js';
import type { AuditEvent, AuditEventInput } from './types.js';
export function emitAuditEvent(input: AuditEventInput | (() => AuditEventInput)): void {
if (!AUDIT_ENABLED) return; // The one opt-in check — the whole feature switches here.
try {
// Lazy inputs keep a disabled box at literally zero audit work (and shield
// the action from assembly errors — origin lookups can touch the DB).
if (typeof input === 'function') input = input();
const event: AuditEvent = {
event_id: randomUUID(),
time: new Date().toISOString(),
schema_version: 1,
// Directory-enrichment fields stamp null until the adapter lands.
actor: { ...input.actor, email: null, user_id: null, group_ids: null },
origin: input.origin,
action: input.action,
resources: input.resources,
outcome: input.outcome,
correlation_id: input.correlationId ?? null,
details: redactDetails(input.details ?? {}),
};
const line = JSON.stringify(event);
appendAuditLine(line);
// Post-write hooks: fired only after the append succeeded, so an exporter
// can never know an event the source of truth doesn't. Failures are
// isolated inside notifyAuditHooks.
notifyAuditHooks(event, line);
// eslint-disable-next-line no-catch-all/no-catch-all -- fail-open is the contract: auditing must never take down the audited action
} catch (err) {
// Fail-open + loud: the audited action must proceed even when the log
// can't be written (a full disk must not brick recovery commands).
const action = typeof input === 'function' ? undefined : input.action;
log.error('Audit append failed — action proceeding (fail-open)', { action, err });
}
}
@@ -0,0 +1,200 @@
/**
* Post-write hook contract: hooks observe the LOG (fire only after a
* successful append, exported ⊆ written), failures are isolated everywhere,
* and the lifecycle (init/maintain/shutdown) behaves.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ enabled: true, appendThrows: false, appended: [] as string[] }));
vi.mock('../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config.js')>();
return {
...actual,
get AUDIT_ENABLED() {
return state.enabled;
},
AUDIT_RETENTION_DAYS: 90,
DATA_DIR: '/tmp/nanoclaw-test-hooks-unused',
};
});
vi.mock('./store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
if (state.appendThrows) throw new Error('disk full');
state.appended.push(line);
},
};
});
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
let hooks: typeof import('./hooks.js');
let emit: typeof import('./emit.js');
let log: (typeof import('../log.js'))['log'];
beforeEach(async () => {
state.enabled = true;
state.appendThrows = false;
state.appended.length = 0;
vi.resetModules(); // fresh hook registry per test
hooks = await import('./hooks.js');
emit = await import('./emit.js');
log = (await import('../log.js')).log;
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
const EVENT_INPUT = {
actor: { type: 'human' as const, id: 'host:test' },
origin: { transport: 'socket' as const },
action: 'groups.list',
resources: [{ type: 'agent_group' }],
outcome: 'success' as const,
details: { limit: 5 },
};
describe('post-write notification', () => {
it('calls a registered hook with the parsed event and the exact stored line', () => {
const seen: Array<{ event: import('./types.js').AuditEvent; line: string }> = [];
hooks.registerAuditHook({ name: 'demo', onEvent: (event, line) => seen.push({ event, line }) });
emit.emitAuditEvent(EVENT_INPUT);
expect(state.appended).toHaveLength(1);
expect(seen).toHaveLength(1);
expect(seen[0].line).toBe(state.appended[0]);
expect(JSON.parse(seen[0].line)).toEqual(seen[0].event);
expect(seen[0].event.action).toBe('groups.list');
});
it('does NOT call hooks when the local append fails — exported ⊆ written', () => {
const onEvent = vi.fn();
hooks.registerAuditHook({ name: 'demo', onEvent });
state.appendThrows = true;
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow(); // action still proceeds
expect(onEvent).not.toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Audit append failed'), expect.anything());
});
it('does NOT call hooks when audit is disabled', () => {
const onEvent = vi.fn();
hooks.registerAuditHook({ name: 'demo', onEvent });
state.enabled = false;
emit.emitAuditEvent(EVENT_INPUT);
expect(state.appended).toHaveLength(0);
expect(onEvent).not.toHaveBeenCalled();
});
it('isolates a throwing hook: the write survives, later hooks still run, the action proceeds', () => {
const second = vi.fn();
hooks.registerAuditHook({
name: 'broken',
onEvent: () => {
throw new Error('exporter exploded');
},
});
hooks.registerAuditHook({ name: 'healthy', onEvent: second });
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow();
expect(state.appended).toHaveLength(1); // the log has the event regardless
expect(second).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(
expect.stringContaining('Audit hook threw'),
expect.objectContaining({ hook: 'broken', action: 'groups.list' }),
);
});
});
describe('lifecycle', () => {
it('initAuditHooks surfaces a failing init as a fatal error naming the hook', () => {
hooks.registerAuditHook({ name: 'ok', onEvent: () => {}, init: vi.fn() });
hooks.registerAuditHook({
name: 'bad-boot',
onEvent: () => {},
init: () => {
throw new Error('no route to collector');
},
});
expect(() => hooks.initAuditHooks()).toThrow(/audit hook "bad-boot" failed to initialize.*no route/);
});
it('maintainAuditHooks calls every maintain and isolates throws', () => {
const m1 = vi.fn(() => {
throw new Error('flush failed');
});
const m2 = vi.fn();
hooks.registerAuditHook({ name: 'a', onEvent: () => {}, maintain: m1 });
hooks.registerAuditHook({ name: 'b', onEvent: () => {}, maintain: m2 });
hooks.registerAuditHook({ name: 'c', onEvent: () => {} }); // no maintain — fine
expect(() => hooks.maintainAuditHooks()).not.toThrow();
expect(m1).toHaveBeenCalledTimes(1);
expect(m2).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(
expect.stringContaining('maintenance failed'),
expect.objectContaining({ hook: 'a' }),
);
});
it('shutdownAuditHooks awaits async shutdowns and isolates throws', async () => {
const order: string[] = [];
hooks.registerAuditHook({
name: 'a',
onEvent: () => {},
shutdown: async () => {
await Promise.resolve();
order.push('a');
},
});
hooks.registerAuditHook({
name: 'b',
onEvent: () => {},
shutdown: () => {
throw new Error('handle already closed');
},
});
hooks.registerAuditHook({
name: 'c',
onEvent: () => {},
shutdown: () => {
order.push('c');
},
});
await hooks.shutdownAuditHooks();
expect(order).toEqual(['a', 'c']);
expect(log.error).toHaveBeenCalledWith(
expect.stringContaining('shutdown failed'),
expect.objectContaining({ hook: 'b' }),
);
});
it('maintainAudit skips hook maintenance when audit is disabled', async () => {
const init = await import('./init.js');
const maintain = vi.fn();
hooks.registerAuditHook({ name: 'a', onEvent: () => {}, maintain });
state.enabled = false;
init.maintainAudit();
expect(maintain).not.toHaveBeenCalled();
state.enabled = true;
init.maintainAudit();
expect(maintain).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,92 @@
/**
* Post-write audit hooks — the in-process extension seam.
*
* A hook observes the audit LOG, not the event stream: `onEvent` fires only
* after an event has been durably appended to the local day-file, so anything
* a hook exports is guaranteed to exist in the source of truth
* (exported ⊆ written). If the local append fails, hooks are not called —
* and a hook that misses events (crash, restart) catches up by reading the
* day-files, which is the at-least-once story.
*
* Registration follows the tree's observer idiom (registerApprovalResolvedHandler,
* registerResponseHandler, …): an in-tree or skill-installed module calls
* `registerAuditHook(...)` at import time — no core edits, and credentials or
* transport for an external system live in that module, never here.
*/
import { log } from '../log.js';
import type { AuditEvent } from './types.js';
export interface AuditHook {
/** Short identifier used in logs and lifecycle errors. */
name: string;
/**
* Called after a successful local append. `line` is the exact stored bytes
* (one NDJSON line, no trailing newline); `event` is the parsed record.
* MUST be fast and non-blocking — this runs on the audited action's call
* path. A real exporter buffers here and does its IO from `maintain`/its own
* timers. Throwing is tolerated: isolated and logged, never propagated.
*/
onEvent(event: AuditEvent, line: string): void;
/** Boot hook, called only when audit is enabled. Throw = host refuses to start. */
init?(): void;
/** Periodic maintenance — called from the 60s host-sweep (enabled boxes only). */
maintain?(): void;
/** Graceful-shutdown hook (flush buffers, close handles). */
shutdown?(): void | Promise<void>;
}
const hooks: AuditHook[] = [];
export function registerAuditHook(hook: AuditHook): void {
hooks.push(hook);
}
/** Fan out one written event to every hook, isolating failures per hook. */
export function notifyAuditHooks(event: AuditEvent, line: string): void {
for (const hook of hooks) {
try {
hook.onEvent(event, line);
// eslint-disable-next-line no-catch-all/no-catch-all -- isolation is the contract: one bad hook must not affect the log, other hooks, or the audited action
} catch (err) {
log.error('Audit hook threw — event is safely in the log', { hook: hook.name, action: event.action, err });
}
}
}
/** Boot lifecycle. A hook that can't start is a silent-export-gap risk — fatal. */
export function initAuditHooks(): void {
for (const hook of hooks) {
try {
hook.init?.();
} catch (err) {
throw new Error(
`audit hook "${hook.name}" failed to initialize: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
}
/** Sweep lifecycle — periodic maintenance, isolated per hook. */
export function maintainAuditHooks(): void {
for (const hook of hooks) {
try {
hook.maintain?.();
// eslint-disable-next-line no-catch-all/no-catch-all -- one hook's maintenance failure must not stop the others (or the sweep)
} catch (err) {
log.error('Audit hook maintenance failed', { hook: hook.name, err });
}
}
}
/** Shutdown lifecycle — awaited by the host's graceful shutdown. */
export async function shutdownAuditHooks(): Promise<void> {
for (const hook of hooks) {
try {
await hook.shutdown?.();
// eslint-disable-next-line no-catch-all/no-catch-all -- shutdown must drain every hook even when one throws
} catch (err) {
log.error('Audit hook shutdown failed', { hook: hook.name, err });
}
}
}
@@ -0,0 +1,17 @@
/**
* 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';
@@ -0,0 +1,40 @@
/**
* Boot-time audit wiring — composed in src/index.ts alongside the import of
* the domain observer (`approvals-observer.audit.ts`, which self-registers).
*
* 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).
*/
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 { assertAuditWritable, AUDIT_DIR, markPrunedToday, pruneAuditLog, pruneAuditLogIfDue } from './store.js';
export function initAuditLog(): void {
if (!AUDIT_ENABLED) return;
try {
assertAuditWritable();
} catch (err) {
throw new Error(
`AUDIT_ENABLED=true but the audit directory is not writable: ${AUDIT_DIR} (${err instanceof Error ? err.message : String(err)})`,
{ cause: err },
);
}
pruneAuditLog();
markPrunedToday();
initAuditHooks(); // throw → main() exit 1, same posture as the writability assert
onShutdown(() => shutdownAuditHooks());
log.info('Audit log enabled', { dir: AUDIT_DIR, retentionDays: AUDIT_RETENTION_DAYS });
}
/**
* Host-sweep tick: retention prune (throttled to once per UTC day internally)
* plus every hook's periodic maintenance. No-op when audit is disabled.
*/
export function maintainAudit(): void {
pruneAuditLogIfDue();
if (AUDIT_ENABLED) maintainAuditHooks();
}
@@ -0,0 +1,150 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ dataDir: '', enabled: true }));
vi.mock('../config.js', () => ({
get DATA_DIR() {
return state.dataDir;
},
get AUDIT_ENABLED() {
return state.enabled;
},
AUDIT_RETENTION_DAYS: 90,
}));
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
let reader: typeof import('./reader.js');
let store: typeof import('./store.js');
beforeEach(async () => {
state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-reader-'));
state.enabled = true;
vi.resetModules();
store = await import('./store.js');
reader = await import('./reader.js');
});
afterEach(() => {
fs.rmSync(state.dataDir, { recursive: true, force: true });
});
function dayString(daysAgo: number): string {
return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
}
function isoAt(daysAgo: number, tag: number): string {
const base = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
return `${base.toISOString().slice(0, 10)}T10:00:0${tag}.000Z`;
}
let seq = 0;
function seedEvent(daysAgo: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
seq += 1;
const event = {
event_id: `e-${seq}`,
time: isoAt(daysAgo, seq % 10),
schema_version: 1,
actor: { type: 'human', id: 'host:moshe', email: null, user_id: null, group_ids: null },
origin: { transport: 'socket' },
action: 'groups.list',
resources: [{ type: 'agent_group', id: 'ag-1' }],
outcome: 'success',
correlation_id: null,
details: {},
...overrides,
};
const dir = path.join(state.dataDir, 'audit');
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, `${dayString(daysAgo)}.ndjson`), JSON.stringify(event) + '\n');
return event;
}
describe('listAuditEvents', () => {
it('throws the disabled error rather than returning an empty list', () => {
state.enabled = false;
expect(() => reader.listAuditEvents({})).toThrow('audit log is disabled — set AUDIT_ENABLED=true');
});
it('returns flat rows newest-first across day-files, honoring --limit', () => {
seedEvent(2, { event_id: 'old' });
seedEvent(1, { event_id: 'mid-a' });
seedEvent(1, { event_id: 'mid-b' });
seedEvent(0, { event_id: 'new' });
const rows = reader.listAuditEvents({}) as Array<Record<string, unknown>>;
expect(rows.map((r) => r.event_id)).toEqual(['new', 'mid-b', 'mid-a', 'old']);
expect(rows[0]).toMatchObject({ actor: 'host:moshe', action: 'groups.list', outcome: 'success' });
expect(rows[0].resources).toBe('agent_group:ag-1');
const limited = reader.listAuditEvents({ limit: 2 }) as Array<Record<string, unknown>>;
expect(limited.map((r) => r.event_id)).toEqual(['new', 'mid-b']);
});
it('filters by actor, outcome, correlation, and resource (id or type)', () => {
seedEvent(0, { event_id: 'a', actor: { type: 'agent', id: 'ag-1' }, outcome: 'denied' });
seedEvent(0, { event_id: 'b', correlation_id: 'appr-9', resources: [{ type: 'approval', id: 'appr-9' }] });
seedEvent(0, { event_id: 'c', resources: [{ type: 'user', id: 'slack:U1' }] });
expect((reader.listAuditEvents({ actor: 'ag-1' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ outcome: 'denied' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ correlation: 'appr-9' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ resource: 'slack:U1' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ resource: 'approval' }) as unknown[]).length).toBe(1);
});
it('matches actions exactly or by dotted prefix', () => {
seedEvent(0, { event_id: 'cfg', action: 'groups.config.add-mcp-server' });
seedEvent(0, { event_id: 'list', action: 'groups.list' });
seedEvent(0, { event_id: 'other', action: 'sessions.list' });
expect((reader.listAuditEvents({ action: 'groups' }) as unknown[]).length).toBe(2);
expect((reader.listAuditEvents({ action: 'groups.config' }) as unknown[]).length).toBe(1);
expect((reader.listAuditEvents({ action: 'groups.list' }) as unknown[]).length).toBe(1);
// Prefix means dotted segments, not substrings.
expect((reader.listAuditEvents({ action: 'group' }) as unknown[]).length).toBe(0);
});
it('applies --since/--until with relative and ISO forms', () => {
seedEvent(5, { event_id: 'old' });
seedEvent(0, { event_id: 'recent' });
const relative = reader.listAuditEvents({ since: '2d' }) as Array<Record<string, unknown>>;
expect(relative.map((r) => r.event_id)).toEqual(['recent']);
const iso = reader.listAuditEvents({ until: dayString(2) }) as Array<Record<string, unknown>>;
expect(iso.map((r) => r.event_id)).toEqual(['old']);
expect(() => reader.listAuditEvents({ since: 'yesterdayish' })).toThrow('invalid --since');
});
it('--format ndjson returns the stored lines verbatim', () => {
const seeded = seedEvent(0, { event_id: 'x1' });
const out = reader.listAuditEvents({ format: 'ndjson' });
expect(typeof out).toBe('string');
expect(JSON.parse(out as string)).toEqual(seeded);
expect(() => reader.listAuditEvents({ format: 'csv' })).toThrow('invalid --format');
});
it('skips malformed stored lines and still returns the rest', () => {
seedEvent(0, { event_id: 'good' });
fs.appendFileSync(path.join(state.dataDir, 'audit', `${dayString(0)}.ndjson`), 'not-json\n');
const rows = reader.listAuditEvents({}) as Array<Record<string, unknown>>;
expect(rows.map((r) => r.event_id)).toEqual(['good']);
});
it('rejects an unknown --outcome value', () => {
expect(() => reader.listAuditEvents({ outcome: 'meh' })).toThrow('invalid --outcome');
});
it('returns empty when nothing has been recorded yet', () => {
expect(reader.listAuditEvents({})).toEqual([]);
});
});
@@ -0,0 +1,160 @@
/**
* Read-back for `ncl audit list` — a newest-first stream-scan over the
* day-files. No index: fine at v1 volume, and adding one later doesn't change
* the store. NDJSON export returns the stored lines verbatim.
*/
import fs from 'fs';
import path from 'path';
import { AUDIT_ENABLED } from '../config.js';
import { log } from '../log.js';
import { AUDIT_DIR, utcDay } from './store.js';
import type { AuditEvent, AuditOutcome } from './types.js';
const OUTCOMES: ReadonlySet<string> = new Set(['success', 'failure', 'denied', 'pending', 'approved', 'rejected']);
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
const DEFAULT_LIMIT = 100;
export interface AuditQuery {
actor?: string;
/** Exact action or dotted prefix (`groups` matches `groups.config.update`). */
action?: string;
/** Matches any resources[] entry by id or by type. */
resource?: string;
outcome?: AuditOutcome;
sinceMs?: number;
untilMs?: number;
correlation?: string;
limit: number;
}
/** `7d` / `24h` / `30m` relative to now, or an ISO date/datetime (UTC). */
export function parseTimeFlag(value: string, flag: string): number {
const rel = /^(\d+)([dhm])$/.exec(value);
if (rel) {
const n = Number(rel[1]);
const unitMs = rel[2] === 'd' ? 86_400_000 : rel[2] === 'h' ? 3_600_000 : 60_000;
return Date.now() - n * unitMs;
}
const abs = Date.parse(value);
if (!Number.isNaN(abs)) return abs;
throw new Error(`invalid ${flag} value "${value}" — use e.g. 7d, 24h, 30m, or an ISO date`);
}
/** Newest first across files and within each file, up to q.limit. */
export function queryAuditEvents(q: AuditQuery): { events: AuditEvent[]; lines: string[] } {
const events: AuditEvent[] = [];
const lines: string[] = [];
let malformed = 0;
for (const { day, file } of dayFilesNewestFirst()) {
if (events.length >= q.limit) break;
// Whole-day skip: a file can't match a window its day lies outside.
if (q.sinceMs !== undefined && day < utcDay(new Date(q.sinceMs))) continue;
if (q.untilMs !== undefined && day > utcDay(new Date(q.untilMs))) continue;
let content: string;
try {
content = fs.readFileSync(file, 'utf8');
// eslint-disable-next-line no-catch-all/no-catch-all -- a torn/pruned day-file must not fail the whole query
} catch (err) {
log.warn('Audit reader failed to read day-file', { file, err });
continue;
}
const fileLines = content.split('\n').filter((l) => l.trim() !== '');
// Lines within a file are chronological — walk backwards for newest-first.
for (let i = fileLines.length - 1; i >= 0 && events.length < q.limit; i--) {
let event: AuditEvent;
try {
event = JSON.parse(fileLines[i]) as AuditEvent;
// eslint-disable-next-line no-catch-all/no-catch-all -- malformed stored lines are skipped (counted + warned below)
} catch {
malformed++;
continue;
}
if (!matches(event, q)) continue;
events.push(event);
lines.push(fileLines[i]);
}
}
if (malformed > 0) {
log.warn('Audit reader skipped malformed lines', { malformed });
}
return { events, lines };
}
function dayFilesNewestFirst(): Array<{ day: string; file: string }> {
let entries: string[];
try {
entries = fs.readdirSync(AUDIT_DIR);
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means no events, not an error
} catch {
return [];
}
return entries
.map((e) => DAY_FILE_RE.exec(e))
.filter((m): m is RegExpExecArray => m !== null)
.map((m) => ({ day: m[1], file: path.join(AUDIT_DIR, m[0]) }))
.sort((a, b) => (a.day < b.day ? 1 : -1));
}
function matches(event: AuditEvent, q: AuditQuery): boolean {
if (q.actor !== undefined && event.actor?.id !== q.actor) return false;
if (q.action !== undefined && event.action !== q.action && !event.action?.startsWith(q.action + '.')) return false;
if (q.outcome !== undefined && event.outcome !== q.outcome) return false;
if (q.correlation !== undefined && event.correlation_id !== q.correlation) return false;
if (q.resource !== undefined) {
const hit = (event.resources ?? []).some((r) => r.id === q.resource || r.type === q.resource);
if (!hit) return false;
}
const t = Date.parse(event.time ?? '');
if (q.sinceMs !== undefined && !(t >= q.sinceMs)) return false;
if (q.untilMs !== undefined && !(t <= q.untilMs)) return false;
return true;
}
/**
* `ncl audit list` handler. Disabled → an explicit error: an empty list would
* read as "no actions happened", which is a different truth than "not
* recording". `--format ndjson` returns the stored lines verbatim (the human
* formatter passes strings through); default returns flat rows for the table.
*/
export function listAuditEvents(args: Record<string, unknown>): string | Array<Record<string, unknown>> {
if (!AUDIT_ENABLED) {
throw new Error('audit log is disabled — set AUDIT_ENABLED=true');
}
const format = args.format !== undefined ? String(args.format) : '';
if (format && format !== 'ndjson') {
throw new Error(`invalid --format "${format}" — only "ndjson" is supported`);
}
const outcome = args.outcome !== undefined ? String(args.outcome) : undefined;
if (outcome !== undefined && !OUTCOMES.has(outcome)) {
throw new Error(`invalid --outcome "${outcome}" — one of: ${[...OUTCOMES].join(', ')}`);
}
const q: AuditQuery = {
actor: args.actor !== undefined ? String(args.actor) : undefined,
action: args.action !== undefined ? String(args.action) : undefined,
resource: args.resource !== undefined ? String(args.resource) : undefined,
outcome: outcome as AuditOutcome | undefined,
correlation: args.correlation !== undefined ? String(args.correlation) : undefined,
sinceMs: args.since !== undefined ? parseTimeFlag(String(args.since), '--since') : undefined,
untilMs: args.until !== undefined ? parseTimeFlag(String(args.until), '--until') : undefined,
limit: args.limit !== undefined ? Math.max(1, Number(args.limit) || DEFAULT_LIMIT) : DEFAULT_LIMIT,
};
const { events, lines } = queryAuditEvents(q);
if (format === 'ndjson') return lines.join('\n');
return events.map((e) => ({
time: e.time,
actor: e.actor?.id ?? '',
action: e.action,
resources: (e.resources ?? []).map((r) => (r.id ? `${r.type}:${r.id}` : r.type)).join(' '),
outcome: e.outcome,
correlation: e.correlation_id ?? '',
event_id: e.event_id,
}));
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { redactDetails } from './redact.js';
describe('redactDetails', () => {
it('masks sensitive keys at any depth, case-insensitively', () => {
const out = redactDetails({
name: 'notion',
env: { NOTION_TOKEN: 'secret-value', SAFE_VALUE: 'ok' },
nested: { Authorization: 'Bearer abc', list: [{ 'api-key': 'k' }, { plain: 'p' }] },
password: 'hunter2',
});
expect(out).toEqual({
name: 'notion',
env: { NOTION_TOKEN: '[REDACTED]', SAFE_VALUE: 'ok' },
nested: { Authorization: '[REDACTED]', list: [{ 'api-key': '[REDACTED]' }, { plain: 'p' }] },
password: '[REDACTED]',
});
});
it('matches the documented key pattern (token|secret|key|password|credential|auth|bearer)', () => {
const out = redactDetails({
access_token: 'x',
clientSecret: 'x',
ssh_key: 'x',
credentials: 'x',
oauth_flow: 'x',
bearerValue: 'x',
username: 'moshe',
});
expect(Object.entries(out).filter(([, v]) => v === '[REDACTED]')).toHaveLength(6);
expect(out.username).toBe('moshe');
});
it('never recurses into a masked key — the whole value is replaced', () => {
const out = redactDetails({ auth: { inner: 'visible?' } });
expect(out.auth).toBe('[REDACTED]');
});
it('truncates strings over 2 KB post-redaction', () => {
const long = 'a'.repeat(5000);
const out = redactDetails({ blob: long, short: 'b' });
expect(out.blob).toBe('a'.repeat(2048) + '…[truncated]');
expect(out.short).toBe('b');
});
it('passes non-string scalars through untouched', () => {
const out = redactDetails({ n: 42, b: true, z: null, u: undefined });
expect(out).toEqual({ n: 42, b: true, z: null, u: undefined });
});
it('caps depth (cycle guard) without throwing', () => {
const cyclic: Record<string, unknown> = { a: 1 };
cyclic.self = cyclic;
const out = redactDetails(cyclic);
let cursor: unknown = out;
for (let i = 0; i < 20 && typeof cursor === 'object' && cursor !== null; i++) {
cursor = (cursor as Record<string, unknown>).self;
}
expect(cursor).toBe('[MAX_DEPTH]');
});
it('does not mutate the input', () => {
const input = { token: 'x', nested: { list: ['a'.repeat(3000)] } };
const snapshot = JSON.parse(JSON.stringify(input));
redactDetails(input);
expect(input).toEqual(snapshot);
});
});
@@ -0,0 +1,39 @@
/**
* Recursive details redaction — runs at the single emit seam so every current
* and future surface is guarded by default. Two rules:
* 1. Any key matching the sensitive pattern is masked to '[REDACTED]'
* (the value is never inspected or recursed into).
* 2. Strings are truncated to ~2 KB post-redaction.
*
* Message bodies are excluded upstream by the per-surface mappers (shape only:
* body_chars, attachment names) — this mask is defense-in-depth, not the
* mechanism that keeps chat content out of the log.
*/
const SENSITIVE_KEY = /(token|secret|key|password|credential|auth|bearer)/i;
const MAX_VALUE_CHARS = 2048;
const MAX_DEPTH = 8;
export function redactDetails(details: Record<string, unknown>): Record<string, unknown> {
return redactObject(details, 0);
}
function redactObject(obj: Record<string, unknown>, depth: number): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = SENSITIVE_KEY.test(k) ? '[REDACTED]' : redactValue(v, depth + 1);
}
return out;
}
function redactValue(value: unknown, depth: number): unknown {
if (typeof value === 'string') {
return value.length > MAX_VALUE_CHARS ? `${value.slice(0, MAX_VALUE_CHARS)}…[truncated]` : value;
}
if (value === null || typeof value !== 'object') return value;
// Depth cap doubles as a cheap cycle guard — details payloads are
// JSON-serializable in practice, but the emit path must never throw.
if (depth > MAX_DEPTH) return '[MAX_DEPTH]';
if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1));
return redactObject(value as Record<string, unknown>, depth);
}
@@ -0,0 +1,183 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Config + log are mocked so store/emit resolve a per-test temp DATA_DIR and
// audit toggles. Getters keep the values live across vi.resetModules().
const state = vi.hoisted(() => ({ dataDir: '', enabled: true, retention: 90 }));
vi.mock('../config.js', () => ({
get DATA_DIR() {
return state.dataDir;
},
get AUDIT_ENABLED() {
return state.enabled;
},
get AUDIT_RETENTION_DAYS() {
return state.retention;
},
}));
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
let store: typeof import('./store.js');
let emit: typeof import('./emit.js');
let log: (typeof import('../log.js'))['log'];
beforeEach(async () => {
state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-store-'));
state.enabled = true;
state.retention = 90;
vi.resetModules();
store = await import('./store.js');
emit = await import('./emit.js');
log = (await import('../log.js')).log;
vi.clearAllMocks();
});
afterEach(() => {
fs.chmodSync(path.join(state.dataDir), 0o700);
const auditDir = path.join(state.dataDir, 'audit');
if (fs.existsSync(auditDir)) fs.chmodSync(auditDir, 0o700);
fs.rmSync(state.dataDir, { recursive: true, force: true });
});
function auditDir(): string {
return path.join(state.dataDir, 'audit');
}
function writeDayFile(day: string, lines = 1): void {
fs.mkdirSync(auditDir(), { recursive: true });
fs.writeFileSync(path.join(auditDir(), `${day}.ndjson`), '{"x":1}\n'.repeat(lines));
}
function dayString(daysAgo: number): string {
return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
}
const EVENT_INPUT = {
actor: { type: 'human' as const, id: 'host:test' },
origin: { transport: 'socket' as const },
action: 'groups.list',
resources: [{ type: 'agent_group' }],
outcome: 'success' as const,
};
describe('appendAuditLine', () => {
it("appends one line to today's UTC day-file, creating the directory lazily", () => {
expect(fs.existsSync(auditDir())).toBe(false);
store.appendAuditLine('{"a":1}');
store.appendAuditLine('{"b":2}');
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
expect(fs.readFileSync(file, 'utf8')).toBe('{"a":1}\n{"b":2}\n');
});
});
describe('emitAuditEvent', () => {
it('writes a schema_version-1 record with envelope fields and null enrichment', () => {
emit.emitAuditEvent({ ...EVENT_INPUT, details: { limit: 100 } });
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
const record = JSON.parse(fs.readFileSync(file, 'utf8').trim());
expect(record).toMatchObject({
schema_version: 1,
actor: { type: 'human', id: 'host:test', email: null, user_id: null, group_ids: null },
origin: { transport: 'socket' },
action: 'groups.list',
outcome: 'success',
correlation_id: null,
details: { limit: 100 },
});
expect(record.event_id).toMatch(/^[0-9a-f-]{36}$/);
expect(new Date(record.time).toISOString()).toBe(record.time);
});
it('redacts details at the emit seam', () => {
emit.emitAuditEvent({ ...EVENT_INPUT, details: { env: { API_TOKEN: 'x' } } });
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
expect(fs.readFileSync(file, 'utf8')).toContain('"API_TOKEN":"[REDACTED]"');
});
it('is a no-op when audit is disabled — the directory is never created', () => {
state.enabled = false;
emit.emitAuditEvent(EVENT_INPUT);
expect(fs.existsSync(auditDir())).toBe(false);
});
it('fails open and loud when the append fails', () => {
fs.mkdirSync(auditDir(), { recursive: true });
fs.chmodSync(auditDir(), 0o500);
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow();
expect(log.error).toHaveBeenCalledWith(
expect.stringContaining('Audit append failed'),
expect.objectContaining({ action: 'groups.list' }),
);
});
});
describe('assertAuditWritable', () => {
it('creates the directory and probes it with a zero-byte append', () => {
store.assertAuditWritable();
expect(fs.existsSync(path.join(auditDir(), `${store.utcDay()}.ndjson`))).toBe(true);
});
it('throws when the directory is not writable', () => {
fs.mkdirSync(auditDir(), { recursive: true });
fs.chmodSync(auditDir(), 0o500);
expect(() => store.assertAuditWritable()).toThrow();
});
});
describe('pruneAuditLog', () => {
it('unlinks only day-files strictly older than the horizon', () => {
writeDayFile(dayString(100));
writeDayFile(dayString(91));
writeDayFile(dayString(90));
writeDayFile(dayString(1));
fs.writeFileSync(path.join(auditDir(), 'not-a-day-file.txt'), 'keep');
fs.writeFileSync(path.join(auditDir(), '2026-01-01.ndjson.bak'), 'keep');
store.pruneAuditLog(90);
const left = fs.readdirSync(auditDir()).sort();
expect(left).toEqual(
[`${dayString(90)}.ndjson`, `${dayString(1)}.ndjson`, '2026-01-01.ndjson.bak', 'not-a-day-file.txt'].sort(),
);
});
it('keeps forever when retention is 0', () => {
writeDayFile(dayString(400));
store.pruneAuditLog(0);
expect(fs.existsSync(path.join(auditDir(), `${dayString(400)}.ndjson`))).toBe(true);
});
it('is a no-op when the audit directory does not exist', () => {
expect(() => store.pruneAuditLog(90)).not.toThrow();
});
});
describe('pruneAuditLogIfDue', () => {
it('prunes at most once per UTC day', () => {
writeDayFile(dayString(100));
store.pruneAuditLogIfDue();
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(false);
writeDayFile(dayString(100));
store.pruneAuditLogIfDue();
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
});
it('does nothing when audit is disabled', () => {
state.enabled = false;
writeDayFile(dayString(100));
store.pruneAuditLogIfDue();
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
});
it('does nothing after markPrunedToday until the day rolls over', () => {
store.markPrunedToday();
writeDayFile(dayString(100));
store.pruneAuditLogIfDue();
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
});
});
@@ -0,0 +1,96 @@
/**
* Audit day-file store — the tree's first structured file writer.
*
* Daily NDJSON files under data/audit/, named <UTC-day>.ndjson. Append-only is
* structural: nothing in the system can update a line. Retention is unlinking
* whole day-files past the horizon — a literal hard delete, no VACUUM. The
* host process is the single writer by construction (both ncl transports and
* every approval converge host-side).
*/
import fs from 'fs';
import path from 'path';
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS, DATA_DIR } from '../config.js';
import { log } from '../log.js';
export const AUDIT_DIR = path.join(DATA_DIR, 'audit');
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
const DAY_MS = 24 * 60 * 60 * 1000;
/** UTC day string (YYYY-MM-DD) — day-file names and prune boundaries. */
export function utcDay(d: Date = new Date()): string {
return d.toISOString().slice(0, 10);
}
export function dayFilePath(day: string): string {
return path.join(AUDIT_DIR, `${day}.ndjson`);
}
/**
* The single append point. Throws on fs failure — emitAuditEvent catches
* (fail-open + loud lives there, not here).
*/
export function appendAuditLine(line: string): void {
fs.mkdirSync(AUDIT_DIR, { recursive: true });
fs.appendFileSync(dayFilePath(utcDay()), line + '\n');
}
/**
* Boot-time writability assert — called only when AUDIT_ENABLED. A zero-byte
* append is a true write probe (fs.access can pass on read-only mounts).
* Throws so main() refuses to start rather than run with a silent audit gap.
*/
export function assertAuditWritable(): void {
fs.mkdirSync(AUDIT_DIR, { recursive: true });
fs.appendFileSync(dayFilePath(utcDay()), '');
}
/**
* Unlink day-files strictly older than (today UTC retentionDays). 0 or
* negative = keep forever. Lexicographic compare is correct for ISO days.
*/
export function pruneAuditLog(retentionDays: number = AUDIT_RETENTION_DAYS): void {
if (retentionDays <= 0) return;
let entries: string[];
try {
entries = fs.readdirSync(AUDIT_DIR);
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means nothing to prune
} catch {
return; // Directory absent — nothing recorded yet.
}
const horizon = utcDay(new Date(Date.now() - retentionDays * DAY_MS));
let pruned = 0;
for (const entry of entries) {
const m = DAY_FILE_RE.exec(entry);
if (!m || m[1] >= horizon) continue;
try {
fs.unlinkSync(path.join(AUDIT_DIR, entry));
pruned++;
// eslint-disable-next-line no-catch-all/no-catch-all -- one stubborn file must not stop the rest of the prune
} catch (err) {
log.error('Audit prune failed to unlink day-file', { file: entry, err });
}
}
if (pruned > 0) {
log.info('Audit retention pruned day-files', { pruned, retentionDays });
}
}
// Host-sweep throttle: the sweep ticks every 60s but retention only needs to
// move once per UTC day.
let lastPruneDay: string | null = null;
/** Sweep hook — no-op unless audit is enabled with a finite retention. */
export function pruneAuditLogIfDue(): void {
if (!AUDIT_ENABLED || AUDIT_RETENTION_DAYS <= 0) return;
const today = utcDay();
if (lastPruneDay === today) return;
lastPruneDay = today;
pruneAuditLog();
}
/** Called after the boot-time prune so the first sweep tick doesn't re-prune. */
export function markPrunedToday(): void {
lastPruneDay = utcDay();
}
@@ -0,0 +1,64 @@
/**
* Canonical audit event vocabulary — schema_version 1.
*
* Fields are chosen to project losslessly onto OCSF and Elastic ECS; the
* field mapping lives with the design docs and stays documentation until a
* SIEM forwarder exists.
*/
export type AuditActorType = 'human' | 'agent' | 'system';
export interface AuditActor {
type: AuditActorType;
/** `host:<os-user>` | `<channel>:<handle>` | agent group id | `host` (system). */
id: string;
}
export interface AuditOrigin {
transport: 'socket' | 'container' | 'channel';
session_id?: string;
messaging_group_id?: string;
channel?: string;
}
export interface AuditResource {
type: string;
/** Omitted when only the attempted type is known (e.g. a denied list). */
id?: string;
}
export type AuditOutcome = 'success' | 'failure' | 'denied' | 'pending' | 'approved' | 'rejected';
/** What emit sites provide. Envelope fields are stamped by emitAuditEvent. */
export interface AuditEventInput {
actor: AuditActor;
origin: AuditOrigin;
/** Dotted namespaced verb, e.g. `groups.config.add-mcp-server`. */
action: string;
resources: AuditResource[];
outcome: AuditOutcome;
/** The approval id on gated chains; null/omitted otherwise. */
correlationId?: string | null;
details?: Record<string, unknown>;
}
/**
* Stored record — one NDJSON line. The directory-enrichment fields (email,
* user_id, group_ids) ship in the schema but stamp null until the directory
* adapter lands; they are filled at write time, never backfilled.
*/
export interface AuditEvent {
event_id: string;
time: string;
schema_version: 1;
actor: AuditActor & { email: null; user_id: null; group_ids: null };
origin: AuditOrigin;
action: string;
resources: AuditResource[];
outcome: AuditOutcome;
correlation_id: string | null;
details: Record<string, unknown>;
}
/** Actor for timer/sweep-driven outcomes (OneCLI expiry, startup sweeps, ghost rejects). */
export const SYSTEM_ACTOR: AuditActor = { type: 'system', id: 'host' };
@@ -0,0 +1,55 @@
/**
* 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' };
}
@@ -0,0 +1,222 @@
/**
* Audit middleware behavior of the exported dispatch — what gets recorded,
* for whom, and when nothing must be recorded. Mirrors dispatch.test.ts
* mocking; audit is force-enabled and the store's append is captured.
*/
import os from 'os';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
vi.mock('../audit/store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../audit/store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const mockGetContainerConfig = vi.fn();
vi.mock('../db/container-configs.js', () => ({
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
}));
vi.mock('../db/agent-groups.js', () => ({
getAgentGroup: vi.fn(() => ({ id: 'g1', name: 'Group One' })),
}));
vi.mock('../db/sessions.js', () => ({
getSession: vi.fn(() => ({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' })),
}));
vi.mock('../db/messaging-groups.js', () => ({
getMessagingGroup: vi.fn(() => ({ channel_type: 'slack' })),
}));
const mockGetResource = vi.fn();
vi.mock('./crud.js', () => ({
getResource: (...args: unknown[]) => mockGetResource(...args),
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
}));
import { register } from './registry.js';
register({
name: 'groups-test',
description: 'echo command on the groups resource',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'groups-get',
description: 'echo command for dash-joined id resolution',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'wirings-list',
description: 'not on the group-scope allowlist',
resource: 'wirings',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => [],
});
register({
name: 'groups-fail',
description: 'handler that throws',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => {
throw new Error('boom');
},
});
register({
name: 'groups-gated',
description: 'approval-gated command',
resource: 'groups',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => 'ran',
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
const AGENT_CTX: CallerContext = { caller: 'agent', sessionId: 's1', agentGroupId: 'g1', messagingGroupId: 'mg1' };
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
vi.clearAllMocks();
appended.lines.length = 0;
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetResource.mockImplementation((plural: string) => (plural === 'groups' ? { scopeField: 'id' } : undefined));
});
describe('withAudit(dispatch)', () => {
it('records a success event for a host caller with socket origin and host actor', async () => {
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, { caller: 'host' });
expect(resp.ok).toBe(true);
const [event] = events();
expect(event).toMatchObject({
schema_version: 1,
actor: { type: 'human', id: `host:${os.userInfo().username}`, email: null },
origin: { transport: 'socket' },
action: 'groups.test',
outcome: 'success',
correlation_id: null,
details: { foo: 'bar' },
});
});
it('records effective args after group auto-fill, with container origin and channel', async () => {
await dispatch({ id: '1', command: 'groups-test', args: {} }, AGENT_CTX);
const [event] = events();
expect(event.actor).toMatchObject({ type: 'agent', id: 'g1' });
expect(event.origin).toEqual({
transport: 'container',
session_id: 's1',
messaging_group_id: 'mg1',
channel: 'slack',
});
expect(event.details).toMatchObject({ id: 'g1', agent_group_id: 'g1', group: 'g1' });
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'g1' });
});
it('records a denied event for a scope denial, naming the attempted resource type', async () => {
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, AGENT_CTX);
expect(resp.ok).toBe(false);
const [event] = events();
expect(event).toMatchObject({
action: 'wirings.list',
outcome: 'denied',
resources: [{ type: 'wirings' }],
details: { error: 'forbidden' },
});
expect(event.details.reason).toContain('scoped');
});
it('records a failure event when the handler throws', async () => {
await dispatch({ id: '1', command: 'groups-fail', args: {} }, { caller: 'host' });
const [event] = events();
expect(event).toMatchObject({ action: 'groups.fail', outcome: 'failure', details: { error: 'handler-error' } });
expect(event.details.reason).toContain('boom');
});
it('records nothing for an approval-pending response — the pending event belongs to requestApproval', async () => {
const resp = await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX);
expect(resp.ok).toBe(false);
if (!resp.ok) expect(resp.error.code).toBe('approval-pending');
expect(appended.lines).toHaveLength(0);
});
it('records an approved replay with the approval id as correlation_id', async () => {
await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX, {
approved: true,
approvalId: 'appr-123-abc',
});
const [event] = events();
expect(event).toMatchObject({ action: 'groups.gated', outcome: 'success', correlation_id: 'appr-123-abc' });
});
it('records unknown commands as cli.unknown-command with the raw name in details', async () => {
await dispatch({ id: '1', command: 'nope-nothing', args: {} }, { caller: 'host' });
const [event] = events();
expect(event).toMatchObject({
action: 'cli.unknown-command',
outcome: 'failure',
resources: [],
details: { command: 'nope-nothing', error: 'unknown-command' },
});
});
it('records the resolved command and id for dash-joined positional ids', async () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
const [event] = events();
expect(event).toMatchObject({ action: 'groups.get', outcome: 'success' });
expect(event.resources).toContainEqual({ type: 'agent_group', id: uuid });
expect(event.details.id).toBe(uuid);
});
it('normalizes hyphenated arg keys in details', async () => {
await dispatch({ id: '1', command: 'groups-test', args: { 'dry-run': 'true' } }, { caller: 'host' });
const [event] = events();
expect(event.details).toMatchObject({ dry_run: 'true' });
});
});
@@ -0,0 +1,123 @@
/**
* 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;
};
}
@@ -0,0 +1,50 @@
/**
* `ncl audit` — read-only query surface over the local audit log
* (append-only NDJSON day-files under data/audit/, see src/audit/).
*
* Scope: host callers and global-scope agents only. `audit` is deliberately
* NOT on the dispatcher's group-scope allowlist, so group-scoped agents are
* refused before any handler runs (fails closed) — the log spans groups.
* Requires AUDIT_ENABLED=true; a disabled box errors rather than returning an
* empty list that would read as "no actions happened".
*/
import { listAuditEvents } from '../../audit/reader.js';
import { registerResource } from '../crud.js';
registerResource({
name: 'audit_event',
plural: 'audit',
// File-backed, not a table — safe because no generic operations are
// declared, so nothing ever queries this name.
table: '(data/audit/*.ndjson)',
description: 'Local audit log — one event per ncl command and host-routed approval. Newest first.',
idColumn: 'event_id',
columns: [
{
name: 'actor',
type: 'string',
description: 'Filter: exact actor id (host:<user>, <channel>:<handle>, agent group id)',
},
{ name: 'action', type: 'string', description: 'Filter: exact action or dotted prefix (e.g. groups.config)' },
{ name: 'resource', type: 'string', description: 'Filter: matches any event resource by id or type' },
{
name: 'outcome',
type: 'string',
description: 'Filter: event outcome',
enum: ['success', 'failure', 'denied', 'pending', 'approved', 'rejected'],
},
{ name: 'since', type: 'string', description: 'Window start: 7d / 24h / 30m relative, or ISO date' },
{ name: 'until', type: 'string', description: 'Window end: same formats as --since' },
{ name: 'correlation', type: 'string', description: 'Filter: approval id tying a gated chain together' },
{ name: 'limit', type: 'number', description: 'Max events (default 100, newest first)' },
{ name: 'format', type: 'string', description: 'Output format', enum: ['ndjson'] },
],
operations: {},
customOperations: {
list: {
access: 'open',
description: 'Query audit events, newest first. --format ndjson streams the stored lines for SIEM export.',
handler: async (args) => listAuditEvents(args),
},
},
});
@@ -0,0 +1,86 @@
/**
* 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' } });
});
});
@@ -0,0 +1,51 @@
/**
* 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;
};
}
@@ -0,0 +1,51 @@
/**
* 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.
*/
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';
export function onApprovalResolved(event: ApprovalResolvedEvent): void {
emitAuditEvent(() => {
const payload = safeParse(event.approval.payload);
return {
// '' resolver = sweep/timer (e.g. the awaiting-reason ghost sweep) → system.
actor: humanOrSystemActor(event.userId),
// Decisions are card clicks on a chat platform, even system-finalized
// ones — the card lifecycle is the surface.
origin: channelOriginForUser(event.userId),
action: 'approvals.decide',
resources: [
...resourcesForApproval(event.approval.action, payload, event.session),
{ type: 'approval', id: event.approval.approval_id },
],
outcome: event.outcome === 'approve' ? 'approved' : 'rejected',
correlationId: event.approval.approval_id,
details: {
gated_action: auditActionForApproval(event.approval.action, payload),
requested_by: event.session.agent_group_id,
},
};
});
}
function safeParse(json: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(json);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
// eslint-disable-next-line no-catch-all/no-catch-all -- stored payloads are untrusted; a malformed one must not break the decision event
} catch {
return {};
}
}
registerApprovalResolvedHandler(onApprovalResolved);
@@ -0,0 +1,127 @@
/**
* 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);
});
});
@@ -0,0 +1,345 @@
/**
* 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',
}),
);
}
};
}
@@ -0,0 +1,164 @@
/**
* Pending-event coverage for the decorated requestApproval: the audit
* decorator emits exactly one `pending` event per successfully queued hold,
* naming the approval and the picked approver — and nothing when no hold
* reached an approver.
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-primitive-audit', AUDIT_ENABLED: true };
});
const TEST_DIR = '/tmp/nanoclaw-test-primitive-audit';
vi.mock('../../audit/store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../audit/store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
// No adapter: card delivery is skipped, the hold still succeeds.
vi.mock('../../delivery.js', () => ({
getDeliveryAdapter: () => null,
}));
// Resolve any approver to a reachable fake DM without the platform stack.
vi.mock('../permissions/user-dm.js', () => ({
ensureUserDm: vi.fn(async () => ({
id: 'mg-dm',
channel_type: 'telegram',
platform_id: 'dm-owner',
})),
}));
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createSession, getPendingApproval } from '../../db/sessions.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { upsertUser } from '../permissions/db/users.js';
import { requestApproval } from './primitive.js';
import type { Session } from '../../types.js';
function now(): string {
return new Date().toISOString();
}
let session: Session;
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
appended.lines.length = 0;
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
session = {
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: now(),
created_at: now(),
};
createSession(session);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
function grantOwner(): void {
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
}
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
describe('decorated requestApproval', () => {
it('emits one pending event naming the approval and the picked approver', async () => {
grantOwner();
await requestApproval({
session,
agentName: 'Agent',
action: 'install_packages',
payload: { apt: ['jq'], npm: [], reason: 'need jq' },
title: 'Install Packages Request',
question: 'ok?',
});
expect(appended.lines).toHaveLength(1);
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'agent', id: 'ag-1' },
origin: { transport: 'container', session_id: 'sess-1' },
action: 'self-mod.install-packages',
outcome: 'pending',
details: { apt: ['jq'], npm: [], reason: 'need jq' },
});
const approvalRef = event.resources.find((r: { type: string }) => r.type === 'approval');
expect(approvalRef.id).toMatch(/^appr-/);
expect(event.correlation_id).toBe(approvalRef.id);
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-1' });
expect(event.resources).toContainEqual({ type: 'user', id: 'telegram:owner' });
// The event references the row that was actually created.
expect(getPendingApproval(approvalRef.id)).toBeDefined();
});
it('emits nothing when no approver is configured (no hold was created)', async () => {
await requestApproval({
session,
agentName: 'Agent',
action: 'install_packages',
payload: { apt: ['jq'], npm: [] },
title: 'Install Packages Request',
question: 'ok?',
});
expect(appended.lines).toHaveLength(0);
});
it('records shape only for the message-bearing a2a gate — never the body', async () => {
grantOwner();
await requestApproval({
session,
agentName: 'Agent',
action: 'a2a_message_gate',
approverUserId: 'telegram:owner',
payload: {
id: 'msg-1',
platform_id: 'ag-target',
content: JSON.stringify({ text: 'hello world', files: ['report.pdf'] }),
in_reply_to: null,
},
title: 'Message approval',
question: 'ok?',
});
const [event] = events();
expect(event.action).toBe('messages.a2a-gate');
expect(event.details).toEqual({ to: 'ag-target', body_chars: 11, attachments: ['report.pdf'] });
expect(JSON.stringify(event)).not.toContain('hello world');
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-target' });
});
});
@@ -0,0 +1,202 @@
/**
* Decision + terminal audit events across the approval resolution paths:
* approvals.decide from the resolved observer, the terminal event from the
* wrapped handler run (skipped for cli_command), and the system actor for
* sweep-finalized rejects.
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-response-handler-audit', AUDIT_ENABLED: true };
});
const TEST_DIR = '/tmp/nanoclaw-test-response-handler-audit';
vi.mock('../../audit/store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../audit/store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
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 { closeDb, initTestDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createPendingApproval, createSession, getPendingApproval, getSession } from '../../db/sessions.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { upsertUser } from '../permissions/db/users.js';
import { finalizeReject } from './finalize.js';
import { registerApprovalHandler } from './primitive.js';
import { handleApprovalsResponse } from './response-handler.js';
function now(): string {
return new Date().toISOString();
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
appended.lines.length = 0;
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
createSession({
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: now(),
created_at: now(),
});
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
function seedApproval(id: string, action: string, payload: Record<string, unknown>): void {
createPendingApproval({
approval_id: id,
session_id: 'sess-1',
request_id: id,
action,
payload: JSON.stringify(payload),
created_at: now(),
title: 'Test approval',
options_json: JSON.stringify([]),
});
}
function click(questionId: string, value: string) {
return handleApprovalsResponse({
questionId,
value,
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
}
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
describe('approval resolution audit events', () => {
it('approve → terminal success (correlated) + approvals.decide with the human approver', async () => {
const handler = vi.fn().mockResolvedValue(undefined);
registerApprovalHandler('install_packages', handler);
seedApproval('appr-ok', 'install_packages', { apt: ['jq'], npm: [] });
await click('appr-ok', 'approve');
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ approvalId: 'appr-ok' }));
const all = events();
expect(all).toHaveLength(2);
const [terminal, decide] = all;
expect(terminal).toMatchObject({
actor: { type: 'agent', id: 'ag-1' },
action: 'self-mod.install-packages',
outcome: 'success',
correlation_id: 'appr-ok',
});
expect(terminal.resources).toContainEqual({ type: 'approval', id: 'appr-ok' });
expect(decide).toMatchObject({
actor: { type: 'human', id: 'telegram:owner' },
origin: { transport: 'channel', channel: 'telegram' },
action: 'approvals.decide',
outcome: 'approved',
correlation_id: 'appr-ok',
details: { gated_action: 'self-mod.install-packages', requested_by: 'ag-1' },
});
});
it('handler failure → terminal failure with the error, decision still recorded', async () => {
registerApprovalHandler('add_mcp_server', async () => {
throw new Error('rebuild exploded');
});
seedApproval('appr-fail', 'add_mcp_server', { name: 'notion', command: 'npx' });
await click('appr-fail', 'approve');
const all = events();
expect(all).toHaveLength(2);
expect(all[0]).toMatchObject({
action: 'self-mod.add-mcp-server',
outcome: 'failure',
correlation_id: 'appr-fail',
});
expect(all[0].details.error).toContain('rebuild exploded');
expect(all[1]).toMatchObject({ action: 'approvals.decide', outcome: 'approved' });
});
it('cli_command approve → decide only; the terminal event belongs to the dispatch middleware', async () => {
registerApprovalHandler('cli_command', vi.fn().mockResolvedValue(undefined));
seedApproval('appr-cli', 'cli_command', {
frame: { id: '1', command: 'groups-update', args: { id: 'ag-1' } },
callerContext: { caller: 'agent', sessionId: 'sess-1', agentGroupId: 'ag-1', messagingGroupId: 'mg-1' },
});
await click('appr-cli', 'approve');
const all = events();
expect(all).toHaveLength(1);
expect(all[0]).toMatchObject({ action: 'approvals.decide', outcome: 'approved', correlation_id: 'appr-cli' });
});
it('reject click → approvals.decide rejected, no terminal event', async () => {
registerApprovalHandler('install_packages', vi.fn());
seedApproval('appr-rej', 'install_packages', { apt: ['jq'], npm: [] });
await click('appr-rej', 'reject');
const all = events();
expect(all).toHaveLength(1);
expect(all[0]).toMatchObject({
actor: { type: 'human', id: 'telegram:owner' },
action: 'approvals.decide',
outcome: 'rejected',
correlation_id: 'appr-rej',
});
expect(getPendingApproval('appr-rej')).toBeUndefined();
});
it('sweep-finalized reject (empty resolver id) → the system actor decides', async () => {
seedApproval('appr-ghost', 'install_packages', { apt: ['jq'], npm: [] });
const approval = getPendingApproval('appr-ghost')!;
const session = getSession('sess-1')!;
await finalizeReject(approval, session, '', 'approver never replied');
const all = events();
expect(all).toHaveLength(1);
expect(all[0]).toMatchObject({
actor: { type: 'system', id: 'host' },
origin: { transport: 'channel' },
action: 'approvals.decide',
outcome: 'rejected',
correlation_id: 'appr-ghost',
});
});
});
@@ -0,0 +1,143 @@
/**
* 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' });
});
});
@@ -0,0 +1,80 @@
/**
* 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;
};
}
+5 -1
View File
@@ -1,5 +1,10 @@
# NanoClaw Security Model
> The canonical, continuously-verified version of this model lives at
> [docs.nanoclaw.dev/concepts/security](https://docs.nanoclaw.dev/concepts/security).
> This in-repo copy can drift; if the two disagree, verify against
> `src/container-runner.ts` (`buildMounts`).
## Trust Model
Privilege is **user-level**, persisted in the `user_roles` table (owner /
@@ -39,7 +44,6 @@ spawn. For the default (Claude) provider these are:
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
| `/workspace/global` | `groups/global/` | RO | Shared global memory |
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
+1 -1
View File
@@ -556,7 +556,7 @@ const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;
const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH;
```
Shared config (DATA_DIR, TIMEZONE, MAX_CONCURRENT_CONTAINERS) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
Shared config (DATA_DIR, TIMEZONE) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
### Code Style
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.36",
"version": "2.1.38",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+6
View File
@@ -232,6 +232,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.list) {
register({
name: `${def.plural}-list`,
action: `${def.plural}.list`,
description: `List all ${def.plural}.`,
access: def.operations.list,
resource: def.plural,
@@ -244,6 +245,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.get) {
register({
name: `${def.plural}-get`,
action: `${def.plural}.get`,
description: `Get a ${def.name} by ID.`,
access: def.operations.get,
resource: def.plural,
@@ -256,6 +258,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.create) {
register({
name: `${def.plural}-create`,
action: `${def.plural}.create`,
description: `Create a new ${def.name}.`,
access: def.operations.create,
resource: def.plural,
@@ -267,6 +270,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.update) {
register({
name: `${def.plural}-update`,
action: `${def.plural}.update`,
description: `Update a ${def.name}.`,
access: def.operations.update,
resource: def.plural,
@@ -278,6 +282,7 @@ export function registerResource(def: ResourceDef): void {
if (def.operations.delete) {
register({
name: `${def.plural}-delete`,
action: `${def.plural}.delete`,
description: `Delete a ${def.name}.`,
access: def.operations.delete,
resource: def.plural,
@@ -291,6 +296,7 @@ export function registerResource(def: ResourceDef): void {
for (const [verb, op] of Object.entries(def.customOperations)) {
register({
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
action: `${def.plural}.${verb.replace(/ /g, '.')}`,
description: op.description,
access: op.access,
resource: def.plural,
+29 -4
View File
@@ -12,11 +12,27 @@ import { getSession } from '../db/sessions.js';
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { getResource } from './crud.js';
import { lookup } from './registry.js';
import { type CommandDef, lookup } from './registry.js';
type DispatchOptions = {
/**
* 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.
*/
export type DispatchTrace = {
cmd?: CommandDef;
command?: string;
args?: Record<string, unknown>;
};
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. */
approvalId?: string;
/** Filled by dispatch with the resolved command + effective args. */
trace?: DispatchTrace;
};
export async function dispatch(
@@ -48,6 +64,12 @@ export async function dispatch(
}
}
if (opts.trace) {
opts.trace.cmd = cmd;
opts.trace.command = req.command;
opts.trace.args = req.args;
}
if (!cmd) {
return err(req.id, 'unknown-command', `no command "${req.command}"`);
}
@@ -102,6 +124,7 @@ export async function dispatch(
fill.id = req.args.id ?? ctx.agentGroupId;
}
req = { ...req, args: { ...req.args, ...fill } };
if (opts.trace) opts.trace.args = req.args;
// Fail-closed pre-handler check for sessions-get: returns "not found"
// regardless of whether the UUID exists in another group, preventing an
@@ -192,10 +215,12 @@ export async function dispatch(
}
}
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
registerApprovalHandler('cli_command', async ({ payload, notify, approvalId }) => {
const frame = payload.frame as RequestFrame;
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
const response = await dispatch(frame, callerContext, { approved: true });
// approvalId rides opts so middleware/observers around dispatch can
// correlate the replay with the approval that authorized it.
const response = await dispatch(frame, callerContext, { approved: true, approvalId });
if (response.ok) {
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
+14 -2
View File
@@ -31,18 +31,30 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
* Custom operations return ad-hoc shapes and leave this undefined.
*/
generic?: 'list' | 'get';
/**
* Canonical dotted action name, e.g. `groups.config.add-mcp-server` for
* middleware and observability surfaces that need namespaced verbs. Stamped
* explicitly by `registerResource` (which knows verb segment boundaries);
* hand-registered commands may omit it and get `name` with dashesdots.
*/
action: string;
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
parseArgs: (raw: Record<string, unknown>) => TArgs;
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
};
/** `register()` input — `action` is defaulted, everything else as stored. */
export type CommandInput<TArgs = unknown, TData = unknown> = Omit<CommandDef<TArgs, TData>, 'action'> & {
action?: string;
};
const registry = new Map<string, CommandDef>();
export function register<TArgs, TData>(def: CommandDef<TArgs, TData>): void {
export function register<TArgs, TData>(def: CommandInput<TArgs, TData>): void {
if (registry.has(def.name)) {
throw new Error(`CLI command "${def.name}" already registered`);
}
registry.set(def.name, def as CommandDef);
registry.set(def.name, { ...def, action: def.action ?? def.name.replace(/-/g, '.') } as CommandDef);
}
export function lookup(name: string): CommandDef | undefined {
+9 -3
View File
@@ -3,9 +3,10 @@
* Spawns agent containers with session folder + agent group folder mounts.
* The container runs the v2 agent-runner which polls the session DB.
*/
import { ChildProcess, execSync, spawn } from 'child_process';
import { ChildProcess, exec, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';
import { OneCLI } from '@onecli-sh/sdk';
@@ -504,6 +505,8 @@ async function buildContainerArgs(
return args;
}
const execAsync = promisify(exec);
/** Build a per-agent-group Docker image with custom packages. */
export async function buildAgentGroupImage(agentGroupId: string): Promise<void> {
const agentGroup = getAgentGroup(agentGroupId);
@@ -539,9 +542,12 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
const tmpDockerfile = path.join(DATA_DIR, `Dockerfile.${agentGroupId}`);
fs.writeFileSync(tmpDockerfile, dockerfile);
try {
execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
// Awaited async exec so the single-threaded host stays responsive during
// the build (can take minutes) instead of blocking on execSync. exec buffers
// stdout/stderr (matching the old stdio: 'pipe') and rejects on a non-zero
// exit, so error propagation is unchanged.
await execAsync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
cwd: DATA_DIR,
stdio: 'pipe',
timeout: 900_000,
});
} finally {
+9 -3
View File
@@ -112,6 +112,11 @@ 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. */
export type CreateAgentResult =
| { ok: true; agentGroupId: string; name: string; localName: string; folder: string }
| { ok: false; reason: string };
/**
* Core creation: writes the new agent group + bidirectional destinations and
* scaffolds its filesystem, then reports via `notify`. Authorization is the
@@ -126,13 +131,13 @@ async function performCreateAgent(
session: Session,
sourceGroup: AgentGroup,
notify: (text: string) => void,
): Promise<void> {
): Promise<CreateAgentResult> {
const localName = normalizeName(name);
// Collision in the creator's destination namespace
if (getDestinationByName(sourceGroup.id, localName)) {
notify(`Cannot create agent "${name}": you already have a destination named "${localName}".`);
return;
return { ok: false, reason: 'destination name collision' };
}
// Derive a safe folder name, deduplicated globally across agent_groups.folder
@@ -149,7 +154,7 @@ async function performCreateAgent(
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
notify(`Cannot create agent "${name}": invalid folder path.`);
log.error('create_agent path traversal attempt', { folder, resolvedPath });
return;
return { ok: false, reason: 'invalid folder path' };
}
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -208,4 +213,5 @@ async function performCreateAgent(
notify(`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`);
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
return { ok: true, agentGroupId, name, localName, folder };
}
@@ -165,6 +165,7 @@ describe('agent message policies', () => {
await applyA2aMessageGate({
session: SA,
userId: 'slack:dana',
approvalId: 'appr-test',
notify,
payload: { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null },
});
+7 -3
View File
@@ -64,8 +64,12 @@ function shortApprovalId(): string {
return `oa-${Math.random().toString(36).slice(2, 10)}`;
}
/** Called from the approvals response handler when a card button is clicked. */
export function resolveOneCLIApproval(approvalId: string, selectedOption: string): boolean {
/**
* 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 {
const state = pending.get(approvalId);
if (!state) return false;
pending.delete(approvalId);
@@ -78,7 +82,7 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string
deletePendingApproval(approvalId);
state.resolve(decision);
log.info('OneCLI approval resolved', { approvalId, decision });
log.info('OneCLI approval resolved', { approvalId, decision, decidedBy: userId || 'system' });
return true;
}
+21 -6
View File
@@ -61,6 +61,8 @@ export interface ApprovalHandlerContext {
payload: Record<string, unknown>;
/** User ID of the admin who approved. Empty string if unknown. */
userId: string;
/** pending_approvals id of the approval that authorized this run. */
approvalId: string;
/** Send a system chat message to the requesting agent's session. */
notify: (text: string) => void;
}
@@ -214,19 +216,31 @@ export interface RequestApprovalOptions {
approverUserId?: string;
}
/**
* 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.
*/
export interface ApprovalHold {
approvalId: string;
/** The approver the card was actually delivered to. */
approverUserId: string;
}
/**
* Queue an approval request. Picks an approver, delivers the card to their
* DM, and records the pending_approvals row. Fire-and-forget from the
* caller's perspective — the admin's response kicks off the registered
* approval handler for this action via the response dispatcher.
* 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.
*/
export async function requestApproval(opts: RequestApprovalOptions): Promise<void> {
export async function requestApproval(opts: RequestApprovalOptions): Promise<ApprovalHold | null> {
const { session, action, payload, title, question, agentName, approverUserId } = opts;
const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id);
if (approvers.length === 0) {
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
return;
return null;
}
const originChannelType = session.messaging_group_id
@@ -236,7 +250,7 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<voi
const target = await pickApprovalDelivery(approvers, originChannelType);
if (!target) {
notifyAgent(session, `${action} failed: no DM channel found for any eligible approver.`);
return;
return null;
}
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -272,9 +286,10 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<voi
} catch (err) {
log.error('Failed to deliver approval card', { action, approvalId, err });
notifyAgent(session, `${action} failed: could not deliver approval request to ${target.userId}.`);
return;
return null;
}
}
log.info('Approval requested', { action, approvalId, agentName, approver: target.userId });
return { approvalId, approverUserId: target.userId };
}
+2 -2
View File
@@ -42,7 +42,7 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
}
if (approval.action === ONECLI_ACTION) {
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
if (resolveOneCLIApproval(payload.questionId, payload.value, namespacedUserId(payload) ?? '')) {
return true;
}
// Row exists but the in-memory resolver is gone (timer fired or the process
@@ -112,7 +112,7 @@ async function handleRegisteredApproval(
const payload = JSON.parse(approval.payload);
try {
await handler({ session, payload, userId, notify });
await handler({ session, payload, userId, approvalId: approval.approval_id, notify });
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 });
+95 -31
View File
@@ -208,6 +208,22 @@ 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.
*
@@ -222,9 +238,9 @@ setSenderScopeGate(
* Deny: delete the row (no "deny list" a future message re-triggers a
* fresh card per ACTION-ITEMS item 5 "no denial persistence").
*/
async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<boolean> {
async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<SenderApprovalResult> {
const row = getPendingSenderApproval(payload.questionId);
if (!row) return false;
if (!row) return { claimed: false };
// payload.userId is the raw platform userId (e.g. "6037840640"); namespace it
// with the channel type so it matches users(id) format. Some platforms
@@ -243,10 +259,18 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<b
clickerId,
expectedApprover: row.approver_user_id,
});
return true; // claim the response so it's not unclaimed-logged, but do nothing
return { claimed: true }; // claim the response so it's not unclaimed-logged, but do nothing
}
const approverId = clickerId;
const approved = payload.value === 'approve';
const decision = {
approved,
senderIdentity: row.sender_identity,
agentGroupId: row.agent_group_id,
messagingGroupId: row.messaging_group_id,
approverId,
channelType: payload.channelType,
};
if (approved) {
addMember({
@@ -272,7 +296,7 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<b
} catch (err) {
log.error('Failed to replay message after sender approval', { approvalId: row.id, err });
}
return true;
return { claimed: true, decision };
}
log.info('Unknown sender denied', {
@@ -282,10 +306,10 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<b
approverId,
});
deletePendingSenderApproval(row.id);
return true;
return { claimed: true, decision };
}
registerResponseHandler(handleSenderApprovalResponse);
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
// ── Unknown-channel registration flow ──
@@ -293,6 +317,23 @@ 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.
*
@@ -307,9 +348,9 @@ setChannelRequestGate(async (mg, event) => {
* captures the reply and creates immediately)
* reject set denied_at, delete pending row
*/
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<ChannelApprovalResult> {
const row = getPendingChannelApproval(payload.questionId);
if (!row) return false;
if (!row) return { claimed: false };
const clickerId = payload.userId
? payload.userId.includes(':')
@@ -324,9 +365,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
clickerId,
expectedApprover: row.approver_user_id,
});
return true;
return { claimed: true };
}
const approverId = clickerId;
const decisionBase = {
messagingGroupId: row.messaging_group_id,
approverId,
channelType: payload.channelType,
};
// ── Reject / Cancel ──
if (payload.value === REJECT_VALUE) {
@@ -336,7 +382,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
messagingGroupId: row.messaging_group_id,
approverId,
});
return true;
return { claimed: true, decision: { kind: 'rejected', ...decisionBase } };
}
// ── Choose existing agent — send agent-selection follow-up card ──
@@ -347,11 +393,11 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
messagingGroupId: row.messaging_group_id,
approverUserId: row.approver_user_id,
});
return true;
return { claimed: true };
}
const adapter = getDeliveryAdapter();
if (!adapter) return true;
if (!adapter) return { claimed: true };
const agentGroups = getAllAgentGroups();
const options = buildAgentSelectionOptions(agentGroups, approverId);
@@ -378,7 +424,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
err,
});
}
return true;
return { claimed: true };
}
// ── Create new agent — prompt for free-text name ──
@@ -389,7 +435,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
messagingGroupId: row.messaging_group_id,
approverUserId: row.approver_user_id,
});
return true;
return { claimed: true };
}
const adapter = getDeliveryAdapter();
@@ -397,7 +443,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
log.error('Channel registration: no delivery adapter for name prompt', {
messagingGroupId: row.messaging_group_id,
});
return true;
return { claimed: true };
}
awaitingNameInput.set(row.approver_user_id, {
@@ -421,7 +467,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
});
awaitingNameInput.delete(row.approver_user_id);
}
return true;
return { claimed: true };
}
// ── Resolve target agent group (connect to existing or create new) ──
@@ -436,7 +482,10 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
targetAgentGroupId,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
return {
claimed: true,
decision: { kind: 'failed', reason: 'target agent group no longer exists', ...decisionBase },
};
}
if (!hasAdminPrivilege(approverId, targetAgentGroupId)) {
log.warn('Channel registration: target agent group rejected for unauthorized approver', {
@@ -444,14 +493,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
targetAgentGroupId,
approverId,
});
return true;
return { claimed: true };
}
} else {
log.warn('Channel registration: unknown response value', {
messagingGroupId: row.messaging_group_id,
value: payload.value,
});
return true;
return { claimed: true };
}
// ── Wire + replay (shared path for connect and create) ──
@@ -464,7 +513,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } };
}
const isGroup = event.threadId !== null;
@@ -512,22 +561,26 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
err,
});
}
return true;
return {
claimed: true,
decision: { kind: 'connected', agentGroupId: targetAgentGroupId, createdAgentGroup: false, ...decisionBase },
};
}
registerResponseHandler(handleChannelApprovalResponse);
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
// ── Free-text name interceptor ──
// Captures the next DM from an approver who clicked "Create new agent",
// creates the agent immediately, wires the channel, and replays.
registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
async function channelNameInterceptor(event: InboundEvent): Promise<ChannelApprovalResult> {
const userId = extractAndUpsertUser(event);
if (!userId) return false;
if (!userId) return { claimed: false };
const pending = awaitingNameInput.get(userId);
if (!pending) return false;
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false;
if (!pending) return { claimed: false };
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId)
return { claimed: false };
awaitingNameInput.delete(userId);
@@ -541,11 +594,11 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
if (!text) {
log.warn('Channel registration: empty name reply, ignoring', { userId });
return true;
return { claimed: true };
}
const row = getPendingChannelApproval(pending.channelMgId);
if (!row) return true;
if (!row) return { claimed: true };
const ag = createNewAgentGroup(text);
log.info('Channel registration: new agent group created', {
@@ -554,6 +607,14 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
agentName: ag.name,
folder: ag.folder,
});
const decisionBase = {
messagingGroupId: row.messaging_group_id,
approverId: userId,
channelType: event.channelType,
agentGroupId: ag.id,
createdAgentGroup: true,
agentName: ag.name,
};
let originalEvent: InboundEvent;
try {
@@ -564,7 +625,8 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
// The group WAS created above — the decision descriptor must say so.
return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } };
}
const isGroup = originalEvent.threadId !== null;
@@ -628,5 +690,7 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
.catch(() => {});
}
}
return true;
});
return { claimed: true, decision: { kind: 'connected', ...decisionBase } };
}
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);