Compare commits

..

1 Commits

Author SHA1 Message Date
Moshe Krupper 8e3276edba feat: opt-in local audit log (AUDIT_ENABLED) — domain-owned adapters
The audit log in trunk, in the adapter shape: src/audit/ is a domain-free
leaf (canonical event schema, single emit seam with the one opt-in check,
append-only NDJSON day-file store, reader, retention prune, redactor, and
registerAuditHook post-write hooks for in-process exporters). What gets
audited lives in domain-owned *.audit.ts adapters beside the code they
observe — dispatch middleware, requestApproval decorator, approval-resolved
observer, OneCLI credential wrappers, permissions card decisions, ungated
create-agent — each composed at its module edge in one line on the seam
contracts from the previous commit. Business logic contains zero audit
calls: grep emitAuditEvent outside src/audit/ + *.audit.ts is empty, and
the audit module imports nothing from src/cli/ or src/modules/.

Off by default: nothing is persisted until AUDIT_ENABLED=true; a disabled
box answers ncl audit list with a clear error. Enabled boxes refuse to
boot if data/audit/ isn't writable, appends are fail-open + loud, and
AUDIT_RETENTION_DAYS (default 90, 0 = forever) hard-deletes day-files at
boot + once daily in the host sweep. Exporters/forwarders stay out of
core — they plug into registerAuditHook (skill territory), fired only
after a successful local append.

Zero new dependencies. 664 tests pass (595 base + 69 audit, including an
AST wiring test for the two boot-path integration points).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:39:40 +03:00
40 changed files with 152 additions and 396 deletions
-68
View File
@@ -1,68 +0,0 @@
# Remove /add-audit
Reverses every change apply made. The audit day-files under `data/audit/` are
the operator's records — this removes the recorder, not the recordings; delete
`data/audit/` yourself if you also want the history gone.
## 1. Delete every copied file
```bash
rm -rf src/audit
rm -f src/audit-wiring.test.ts \
src/cli/dispatch.audit.ts src/cli/dispatch.audit.test.ts \
src/cli/resources/audit.ts \
src/modules/approvals/approvals.audit.ts src/modules/approvals/approvals.audit.test.ts \
src/modules/approvals/approvals-observer.audit.ts \
src/modules/approvals/primitive.audit.test.ts src/modules/approvals/response-handler.audit.test.ts \
src/modules/permissions/permissions.audit.ts src/modules/permissions/permissions.audit.test.ts \
src/modules/agent-to-agent/create-agent.audit.ts src/modules/agent-to-agent/create-agent.audit.test.ts
```
## 2. Revert the seam compositions (DELETE lines, do not comment out)
- `src/cli/resources/index.ts`: delete the `import './audit.js';` line.
- `src/cli/dispatch.ts`: delete the `import { withAudit } from './dispatch.audit.js';`
line and the `export const dispatch = withAudit(dispatchInner);` block (with
its comment); rename `async function dispatchInner(` back to
`export async function dispatch(`.
- `src/modules/approvals/primitive.ts`: delete the
`import { auditRequestApproval } from './approvals.audit.js';` line (and its
comment) and the `export const requestApproval = auditRequestApproval(...)`
block; rename `async function requestApprovalInner(` back to
`export async function requestApproval(`.
- `src/modules/approvals/response-handler.ts`: delete the
`import { runApprovedHandler } from './approvals.audit.js';` line; replace the
`await runApprovedHandler(...)` call (and its comment) with:
`await handler({ session, payload, userId, approvalId: approval.approval_id, notify });`
- `src/modules/approvals/onecli-approvals.ts`: delete the adapter import line,
the `recordOneCliHold` const (change `recordOneCliHold({` back to
`createPendingApproval({`), and the three wrapper consts (with comments);
rename the three `...Inner` functions back (`resolveOneCLIApprovalInner`
`export function resolveOneCLIApproval`, `expireApprovalInner`
`expireApproval`, `sweepStaleApprovalsInner``sweepStaleApprovals`).
- `src/modules/permissions/index.ts`: delete the adapter import line; restore
the three plain registrations:
```ts
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
```
- `src/index.ts`: delete the audit block in `main()` (the comment, the two
`await import(...)` lines, and `initAuditLog();`).
- `src/host-sweep.ts`: delete the audit-maintenance `try { ... }` block in
`sweep()` (with its comment).
## 3. Revert config and env
- `src/config.ts`: remove `'AUDIT_ENABLED',` and `'AUDIT_RETENTION_DAYS',`
from the `readEnvFile([...])` array, and delete the
`AUDIT_ENABLED` / `AUDIT_RETENTION_DAYS` export block (with its comments).
- `.env`: remove the `AUDIT_ENABLED` and `AUDIT_RETENTION_DAYS` lines.
## 4. Verify
```bash
pnpm run build && pnpm test
```
Then restart the service.
-303
View File
@@ -1,303 +0,0 @@
---
name: add-audit
description: Add an opt-in local audit log — every ncl command (both transports, including denials), every host-routed approval (pending/decision/terminal, correlated by approval id), permissions card decisions, OneCLI credential holds, and ungated agent creation, written as SIEM-shaped append-only NDJSON day-files under data/audit/. Read back with `ncl audit list`; export exporters via registerAuditHook. Off until AUDIT_ENABLED=true.
---
# /add-audit — opt-in local audit log
Records one canonical, SIEM-shaped event per action — actor, origin, dotted
action, touched resources, outcome, approval correlation — to append-only
NDJSON day-files under `data/audit/`. Feature is **opt-in**: with
`AUDIT_ENABLED` unset nothing is persisted, the emit seam no-ops at one
boolean check, and `data/audit/` is never created.
Architecture: `src/audit/` is a **domain-free leaf** (schema, emit seam,
store, reader, post-write hooks, shared vocabulary). How each domain
describes itself lives in **domain-owned `*.audit.ts` adapter files** next to
the code they observe; each composes at its module's edge in one line.
Business logic contains zero audit calls: `grep emitAuditEvent src/` matches
only `src/audit/` and `*.audit.ts`.
The adapters compose on seams trunk already ships (`DispatchTrace`,
`ApprovalHold`, `SenderApprovalResult`/`ChannelApprovalResult`,
`CreateAgentResult`, the `userId` arg on `resolveOneCLIApproval`). If an edit
below is already present, skip it — apply is safe to re-run.
## Steps
### 1. Copy the files
```bash
cp -R .claude/skills/add-audit/add/src/. src/
```
Adds `src/audit/` (the leaf + its tests), the domain adapters
(`src/cli/dispatch.audit.ts`, `src/modules/approvals/approvals.audit.ts`,
`src/modules/approvals/approvals-observer.audit.ts`,
`src/modules/permissions/permissions.audit.ts`,
`src/modules/agent-to-agent/create-agent.audit.ts`), their tests, the
`ncl audit` resource (`src/cli/resources/audit.ts`), and
`src/audit-wiring.test.ts`. No dependency is added — the module is stdlib-only.
### 2. Register the `ncl audit` resource
Append to `src/cli/resources/index.ts`:
```ts
import './audit.js';
```
### 3. Add the two config vars to `src/config.ts`
Add both names to the `readEnvFile([...])` array:
```ts
'AUDIT_ENABLED',
'AUDIT_RETENTION_DAYS',
```
Then append after the `ONECLI_GATEWAY_CONTAINER` export block:
```ts
// Local audit log — opt-in (installed by /add-audit). Off by default: the
// audit emitter no-ops and data/audit/ is never created.
export const AUDIT_ENABLED = (process.env.AUDIT_ENABLED || envConfig.AUDIT_ENABLED) === 'true';
// Audit day-files older than this many days are unlinked (a hard delete).
// 0 = keep forever. Read only when AUDIT_ENABLED=true.
const auditRetentionRaw = parseInt(process.env.AUDIT_RETENTION_DAYS || envConfig.AUDIT_RETENTION_DAYS || '90', 10);
export const AUDIT_RETENTION_DAYS = Number.isNaN(auditRetentionRaw) ? 90 : auditRetentionRaw;
```
### 4. Compose the dispatch middleware — `src/cli/dispatch.ts`
Add to the import block:
```ts
import { withAudit } from './dispatch.audit.js';
```
Rename the dispatcher (signature line only):
```ts
export async function dispatch( async function dispatchInner(
```
Insert immediately after `dispatchInner`'s closing brace (before the
`registerApprovalHandler('cli_command', ...)` call):
```ts
/**
* Public dispatcher — the audit middleware wraps the inner dispatcher, so the
* socket server, the container delivery-action, and the approved replay are
* all covered without changing a call site.
*/
export const dispatch = withAudit(dispatchInner);
```
### 5. Decorate requestApproval — `src/modules/approvals/primitive.ts`
Add to the import block:
```ts
// Sibling adapter import; it imports this module back type-only (no cycle).
import { auditRequestApproval } from './approvals.audit.js';
```
Rename the request function (signature line only):
```ts
export async function requestApproval( async function requestApprovalInner(
```
Insert immediately after `requestApprovalInner`'s closing brace:
```ts
/**
* Public export — the audit decorator wraps the inner request so every gated
* hold emits its pending event from one place. Pass-through: callers see the
* hold exactly as the inner returns it.
*/
export const requestApproval = auditRequestApproval(requestApprovalInner);
```
### 6. Wrap the approved-handler run — `src/modules/approvals/response-handler.ts`
Add to the import block:
```ts
import { runApprovedHandler } from './approvals.audit.js';
```
Replace the handler invocation inside `handleRegisteredApproval`:
```ts
await handler({ session, payload, userId, approvalId: approval.approval_id, notify });
```
with:
```ts
// runApprovedHandler wraps the invocation to emit the gated chain's
// terminal audit event; rethrows, so the catch below behaves as before.
await runApprovedHandler(
handler,
{ session, payload, userId, approvalId: approval.approval_id, notify },
approval,
session,
);
```
### 7. Wrap the OneCLI paths — `src/modules/approvals/onecli-approvals.ts`
Add to the import block:
```ts
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from './approvals.audit.js';
```
Four compositions:
a. After the `shortApprovalId()` function, add:
```ts
/** Row insert for a hold — the audit wrapper emits the pending event from it. */
const recordOneCliHold = auditOneCliHold(createPendingApproval);
```
and inside `handleRequest`, change the row insert `createPendingApproval({`
to `recordOneCliHold({`.
b. Rename `export function resolveOneCLIApproval(` to
`function resolveOneCLIApprovalInner(` (signature line only) and insert after
its closing brace:
```ts
/**
* The audit wrapper records the decision with the clicking admin as actor
* (OneCLI rows never reach notifyApprovalResolved, so the shared observer
* can't cover them).
*/
export const resolveOneCLIApproval = auditOneCliDecision(resolveOneCLIApprovalInner);
```
c. Rename `async function expireApproval(` to
`async function expireApprovalInner(` and insert after its closing brace:
```ts
/** Timer-driven expiry — the audit wrapper records a system-actor rejection. */
const expireApproval = auditOneCliExpiry(expireApprovalInner);
```
d. Rename `async function sweepStaleApprovals(` to
`async function sweepStaleApprovalsInner(` and insert after its closing brace:
```ts
/** Startup sweep — the audit wrapper records a system-actor rejection per row. */
const sweepStaleApprovals = auditOneCliSweep(sweepStaleApprovalsInner, () =>
getPendingApprovalsByAction(ONECLI_ACTION),
);
```
### 8. Wrap the permissions decisions — `src/modules/permissions/index.ts`
Add to the import block:
```ts
import { auditChannelDecision, auditChannelNameInterceptor, auditSenderDecision } from './permissions.audit.js';
```
Replace the three registration coercions:
```ts
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
registerResponseHandler(auditSenderDecision(handleSenderApprovalResponse));
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
registerResponseHandler(auditChannelDecision(handleChannelApprovalResponse));
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
registerMessageInterceptor(auditChannelNameInterceptor(channelNameInterceptor));
```
### 9. Boot wiring — `src/index.ts`
Insert inside `main()`, after the `migrateGroupsToClaudeLocal();` step and
before the container-runtime step:
```ts
// Audit log (optional — installed by /add-audit; AUDIT_ENABLED gates writes).
// The observer import self-registers approvals.decide; initAuditLog asserts
// data/audit/ is writable when enabled (throw → exit 1) and starts hooks.
await import('./modules/approvals/approvals-observer.audit.js');
const { initAuditLog } = await import('./audit/index.js');
initAuditLog();
```
### 10. Sweep wiring — `src/host-sweep.ts`
Insert inside `sweep()`, immediately before the `setTimeout(sweep, SWEEP_INTERVAL_MS);` reschedule:
```ts
// Audit maintenance (installed by /add-audit) — retention prune (throttled
// to once per UTC day inside the module) + post-write hooks' maintain().
try {
const { maintainAudit } = await import('./audit/index.js');
maintainAudit();
} catch (err) {
log.error('Audit maintenance failed', { err });
}
```
### 11. Enable it
Append to `.env` (this is the point of installing the skill — but the switch
stays yours):
```bash
AUDIT_ENABLED=true
# Optional; default 90. 0 = keep forever.
#AUDIT_RETENTION_DAYS=90
```
### 12. Verify
```bash
pnpm run build
pnpm exec vitest run src/audit src/audit-wiring.test.ts src/cli/dispatch.audit.test.ts \
src/modules/approvals/primitive.audit.test.ts src/modules/approvals/response-handler.audit.test.ts \
src/modules/approvals/approvals.audit.test.ts src/modules/permissions/permissions.audit.test.ts \
src/modules/agent-to-agent/create-agent.audit.test.ts
pnpm test # full suite — the composed system must stay green
```
Then restart the service (macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
Linux: `systemctl --user restart nanoclaw`).
## Usage
```bash
ncl audit list # newest first, default limit 100
ncl audit list --actor host:moshe --since 7d
ncl audit list --outcome denied --since 90d --format ndjson # SIEM export
ncl audit list --correlation appr-... # everything on one gated chain
```
Host socket + `cli_scope: global` agents only; group-scoped agents never see
the resource (audit spans groups — exclusion fails closed). On a disabled box
the command errors with "audit log is disabled" rather than returning an
empty list that would read as history.
Exporters: call `registerAuditHook(...)` (from `src/audit/index.js`) in a
module imported at boot — `onEvent` fires only after a successful local
append, so an exporter can never know an event the source of truth doesn't.
## Semantics worth knowing
- **Fail-open + loud**: a failed append is `log.error`'d and the action
proceeds; at boot (enabled only) the host refuses to start if `data/audit/`
isn't writable.
- **Append-only**: nothing updates a line; retention prune unlinks whole
day-files past `AUDIT_RETENTION_DAYS` (a literal hard delete).
- **No message bodies**: message-bearing events record shape only
(`body_chars`, attachment names); a recursive key-pattern redactor masks
secret-looking values at the emit seam.
+1
View File
@@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **Opt-in local audit log.** `AUDIT_ENABLED=true` records every `ncl` command (both transports, including scope denials) and every host-routed approval — request, decision, and terminal outcome, covering CLI gates, self-mod, a2a message gates, agent creation (incl. the ungated global-scope door and the channel-registration name flow), the permissions sender/channel cards, and OneCLI credential holds — as one canonical SIEM-shaped event per action, appended to NDJSON day-files under `data/audit/`. Gated chains share a `correlation_id` (the approval id); details pass a recursive secret-key redactor and message-bearing events record shape only (never bodies). `AUDIT_RETENTION_DAYS` (default 90; `0` = keep forever) hard-deletes day-files past the horizon at boot + once daily in the host sweep. Read back with `ncl audit list [--actor --action --resource --outcome --since --until --correlation --limit] [--format ndjson]` — host + global-scope callers only. In-process consumers (future exporters) plug in via `registerAuditHook` — post-write hooks with an init/maintain/shutdown lifecycle that fire only after the local append succeeds, so external systems can never be ahead of the source of truth; no forwarder, credentials, or transport ships in core. Off by default: nothing is persisted until enabled, and an enabled box refuses to boot if `data/audit/` isn't writable. See [docs/SECURITY.md](docs/SECURITY.md#6-local-audit-log-opt-in).
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
+2
View File
@@ -71,6 +71,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/audit/` | Opt-in local audit log (`AUDIT_ENABLED=true`): a domain-free leaf — emit seam, append-only NDJSON day-files under `data/audit/`, retention prune, `ncl audit` reader, and `registerAuditHook` post-write hooks for in-process exporters (fire only after a successful local append). What gets audited lives in domain-owned `*.audit.ts` adapters beside the code they observe (dispatch middleware, `requestApproval` decorator, approval-resolved observer, permissions/OneCLI/create-agent seams), each composed at its module edge in one line. See the "Local Audit Log" section in [docs/SECURITY.md](docs/SECURITY.md) |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
@@ -108,6 +109,7 @@ ncl help
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
| audit | list | Local audit log (read-only; host + global scope only; requires `AUDIT_ENABLED=true`; `--format ndjson` for export) |
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
+55
View File
@@ -169,6 +169,61 @@ pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
### 6. Local Audit Log (Opt-In)
Every `ncl` command (both transports — host socket and container — including
denials) and every approval the host routes (request, decision, and terminal
outcome; covering CLI gates, self-mod, a2a message gates, agent creation, the
permissions sender/channel cards, and OneCLI credential holds) is recorded as
one canonical, SIEM-shaped JSON event.
- **Off by default.** Nothing is persisted until an operator sets
`AUDIT_ENABLED=true`; when disabled, the emitter no-ops and `data/audit/` is
never created. `ncl audit list` on a disabled box errors instead of returning
an empty list.
- **Store:** append-only NDJSON day-files under `data/audit/<UTC-day>.ndjson`,
written only by the host process. Retention is a hard delete — whole
day-files past the horizon are unlinked at boot and once daily in the host
sweep.
- **Fail-open + loud:** a failed append is logged and the action proceeds (a
full disk must not brick recovery commands). At boot, an enabled box refuses
to start if `data/audit/` isn't writable.
- **No secrets, no message bodies:** a recursive key mask
(`token|secret|key|password|credential|auth|bearer`) redacts details at the
single emit point, values are truncated to ~2 KB, and message-bearing events
(a2a gates, OneCLI body previews) record shape only — `body_chars` and
attachment names, never content.
- **Scope:** `ncl audit list` is available to host callers and global-scope
agents only. `audit` is not on the group-scope allowlist, so confined agents
are refused before any handler runs.
| Env | Default | Meaning |
| --- | --- | --- |
| `AUDIT_ENABLED` | `false` | Set `true` to record audit events. |
| `AUDIT_RETENTION_DAYS` | `90` | Days before day-files are unlinked; `0` = keep forever. Read only when enabled. |
Read back with `ncl audit list --actor … --action … --resource … --outcome …
--since 7d --correlation … --limit 100`; `--format ndjson` streams the stored
lines for SIEM export. Event fields are chosen to project losslessly onto
OCSF and Elastic ECS; forwarding is a mapping exercise, deferred until a
forwarder exists.
**Integration surfaces** (no push forwarder ships in core — credentials and
transport for external systems never live here):
1. **Tail the store** — any external agent (Vector, Filebeat, Fluent Bit, a
custom daemon) tails `data/audit/*.ndjson`; the format is stable and
`schema_version`-stamped.
2. **Pull via the CLI** — poll `ncl audit list --format ndjson --since …` and
dedupe on `event_id`.
3. **In-process post-write hooks** — a module (in-tree or skill-installed)
calls `registerAuditHook({ name, onEvent, init?, maintain?, shutdown? })`
from `src/audit/`. Hooks fire only **after** an event is durably appended
to the local day-file, so anything exported is guaranteed to exist in the
source of truth; a hook that misses events catches up by reading the
day-files (at-least-once). Hook failures are isolated and logged — they
never affect the log, other hooks, or the audited action.
## Resource Limits
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
@@ -1,8 +1,8 @@
/**
* Wiring test for the /add-audit skill's two boot-path code edits.
* Wiring test for the audit log's two boot-path integration points.
*
* The skill inserts one colocated block into src/index.ts (dynamic import of
* the self-registering approvals observer + `initAuditLog()`), and one into
* One colocated block lives in src/index.ts (dynamic import of
* the self-registering approvals observer + `initAuditLog()`), and one in
* 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
@@ -13,10 +13,8 @@
* 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
* Delete or misplace a block 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';
@@ -68,7 +66,7 @@ function isBareCall(s: ts.Statement, callee: string): boolean {
);
}
describe('/add-audit wiring in src/index.ts', () => {
describe('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) =>
@@ -91,7 +89,7 @@ describe('/add-audit wiring in src/index.ts', () => {
});
});
describe('/add-audit wiring in src/host-sweep.ts', () => {
describe('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()'));
@@ -1,5 +1,5 @@
/**
* Opt-in local audit log installed by /add-audit.
* Opt-in local audit log (docs/SECURITY.md, "Local Audit Log").
*
* 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
@@ -1,5 +1,5 @@
/**
* CLI audit adapter (installed by /add-audit) owns how the dispatcher
* CLI audit adapter 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
+9 -1
View File
@@ -6,6 +6,7 @@
* Approval gating for risky calls from the container is the only branch
* that differs by caller. Host callers and `open` commands run inline.
*/
import { withAudit } from './dispatch.audit.js';
import { getContainerConfig } from '../db/container-configs.js';
import { getAgentGroup } from '../db/agent-groups.js';
import { getSession } from '../db/sessions.js';
@@ -35,7 +36,7 @@ export type DispatchOptions = {
trace?: DispatchTrace;
};
export async function dispatch(
async function dispatchInner(
req: RequestFrame,
ctx: CallerContext,
opts: DispatchOptions = {},
@@ -215,6 +216,13 @@ export async function dispatch(
}
}
/**
* 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);
registerApprovalHandler('cli_command', async ({ payload, notify, approvalId }) => {
const frame = payload.frame as RequestFrame;
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
+1
View File
@@ -14,3 +14,4 @@ import './user-dms.js';
import './dropped-messages.js';
import './approvals.js';
import './sessions.js';
import './audit.js';
+10
View File
@@ -17,6 +17,8 @@ const envConfig = readEnvFile([
'NANOCLAW_EGRESS_LOCKDOWN',
'NANOCLAW_EGRESS_NETWORK',
'ONECLI_GATEWAY_CONTAINER',
'AUDIT_ENABLED',
'AUDIT_RETENTION_DAYS',
]);
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
@@ -64,6 +66,14 @@ export const EGRESS_NETWORK =
export const ONECLI_GATEWAY_CONTAINER =
process.env.ONECLI_GATEWAY_CONTAINER || envConfig.ONECLI_GATEWAY_CONTAINER || 'onecli';
// Local audit log — opt-in (docs/SECURITY.md, "Local Audit Log"). Off by
// default: the audit emitter no-ops and data/audit/ is never created.
export const AUDIT_ENABLED = (process.env.AUDIT_ENABLED || envConfig.AUDIT_ENABLED) === 'true';
// Audit day-files older than this many days are unlinked (a hard delete).
// 0 = keep forever. Read only when AUDIT_ENABLED=true.
const auditRetentionRaw = parseInt(process.env.AUDIT_RETENTION_DAYS || envConfig.AUDIT_RETENTION_DAYS || '90', 10);
export const AUDIT_RETENTION_DAYS = Number.isNaN(auditRetentionRaw) ? 90 : auditRetentionRaw;
// Timezone for scheduled tasks, message formatting, etc.
// Validates each candidate is a real IANA identifier before accepting.
function resolveConfigTimezone(): string {
+9
View File
@@ -164,6 +164,15 @@ async function sweep(): Promise<void> {
}
// MODULE-HOOK:approvals-reason-sweep:end
// Audit maintenance — retention prune (throttled to once per UTC day
// inside the module) + post-write hooks' maintain(). No-op when disabled.
try {
const { maintainAudit } = await import('./audit/index.js');
maintainAudit();
} catch (err) {
log.error('Audit maintenance failed', { err });
}
setTimeout(sweep, SWEEP_INTERVAL_MS);
}
+7
View File
@@ -82,6 +82,13 @@ async function main(): Promise<void> {
// 1c. One-time filesystem cutover — idempotent, no-op after first run.
migrateGroupsToClaudeLocal();
// 1d. Audit log (opt-in — AUDIT_ENABLED gates writes). The observer import
// self-registers approvals.decide; initAuditLog asserts data/audit/ is
// writable when enabled (throw → exit 1) and starts post-write hooks.
await import('./modules/approvals/approvals-observer.audit.js');
const { initAuditLog } = await import('./audit/index.js');
initAuditLog();
// 2. Container runtime
ensureContainerRuntimeRunning();
cleanupOrphans();
@@ -1,5 +1,5 @@
/**
* Agent-creation audit adapter (installed by /add-audit) agents.create for
* Agent-creation audit adapter 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
@@ -1,5 +1,5 @@
/**
* Decision events (installed by /add-audit) approvals.decide, one per
* Decision events approvals.decide, one per
* resolved approval, riding the existing approval-resolved hook. Covers every
* requestApproval-backed action (cli_command, self-mod, create_agent, a2a
* gate); OneCLI never reaches notifyApprovalResolved and emits its decisions
@@ -1,5 +1,5 @@
/**
* Approvals audit adapter (installed by /add-audit) owns how the approvals
* Approvals audit adapter 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
+23 -4
View File
@@ -19,6 +19,7 @@
*/
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from './approvals.audit.js';
import { pickApprovalDelivery, pickApprover } from './primitive.js';
import { ONECLI_API_KEY, ONECLI_URL } from '../../config.js';
import { getAgentGroup } from '../../db/agent-groups.js';
@@ -64,12 +65,15 @@ function shortApprovalId(): string {
return `oa-${Math.random().toString(36).slice(2, 10)}`;
}
/** Row insert for a hold — the audit wrapper emits the pending event from it. */
const recordOneCliHold = auditOneCliHold(createPendingApproval);
/**
* 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 {
function resolveOneCLIApprovalInner(approvalId: string, selectedOption: string, userId: string): boolean {
const state = pending.get(approvalId);
if (!state) return false;
pending.delete(approvalId);
@@ -86,6 +90,13 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string
return true;
}
/**
* The audit wrapper records the decision with the clicking admin as actor
* (OneCLI rows never reach notifyApprovalResolved, so the shared observer
* can't cover them).
*/
export const resolveOneCLIApproval = auditOneCliDecision(resolveOneCLIApprovalInner);
export function startOneCLIApprovalHandler(deliveryAdapter: ChannelDeliveryAdapter): void {
if (handle) return;
adapterRef = deliveryAdapter;
@@ -174,7 +185,7 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
return 'deny';
}
createPendingApproval({
recordOneCliHold({
approval_id: approvalId,
session_id: null,
request_id: request.id,
@@ -218,7 +229,7 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
});
}
async function expireApproval(approvalId: string, reason: string): Promise<void> {
async function expireApprovalInner(approvalId: string, reason: string): Promise<void> {
const rows = getPendingApprovalsByAction(ONECLI_ACTION).filter((r) => r.approval_id === approvalId);
const row = rows[0];
if (!row) return;
@@ -229,6 +240,9 @@ async function expireApproval(approvalId: string, reason: string): Promise<void>
log.info('OneCLI approval expired', { approvalId, reason });
}
/** Timer-driven expiry — the audit wrapper records a system-actor rejection. */
const expireApproval = auditOneCliExpiry(expireApprovalInner);
async function editCardExpired(row: PendingApproval, reason: string): Promise<void> {
if (!adapterRef || !row.platform_message_id || !row.channel_type || !row.platform_id) return;
try {
@@ -248,7 +262,7 @@ async function editCardExpired(row: PendingApproval, reason: string): Promise<vo
}
}
async function sweepStaleApprovals(): Promise<void> {
async function sweepStaleApprovalsInner(): Promise<void> {
const rows = getPendingApprovalsByAction(ONECLI_ACTION);
if (rows.length === 0) return;
log.info('Sweeping stale OneCLI approvals from previous process', { count: rows.length });
@@ -258,6 +272,11 @@ async function sweepStaleApprovals(): Promise<void> {
}
}
/** Startup sweep — the audit wrapper records a system-actor rejection per row. */
const sweepStaleApprovals = auditOneCliSweep(sweepStaleApprovalsInner, () =>
getPendingApprovalsByAction(ONECLI_ACTION),
);
/** The hosted gateway's structured request summary not yet in the SDK's
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
* performed plus labeled fields (To / Subject / Body for email sends). */
+10 -1
View File
@@ -21,6 +21,8 @@
* exposing just user-roles/user-dms) is more churn than it's worth. Revisit
* if either module becomes genuinely optional (see REFACTOR_PLAN open q #3).
*/
// Sibling adapter import; it imports this module back type-only (no cycle).
import { auditRequestApproval } from './approvals.audit.js';
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { createPendingApproval, getSession } from '../../db/sessions.js';
@@ -234,7 +236,7 @@ export interface ApprovalHold {
* response kicks off the registered approval handler for this action via the
* response dispatcher.
*/
export async function requestApproval(opts: RequestApprovalOptions): Promise<ApprovalHold | null> {
async function requestApprovalInner(opts: RequestApprovalOptions): Promise<ApprovalHold | null> {
const { session, action, payload, title, question, agentName, approverUserId } = opts;
const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id);
@@ -293,3 +295,10 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<App
log.info('Approval requested', { action, approvalId, agentName, approver: target.userId });
return { approvalId, approverUserId: target.userId };
}
/**
* Public export the audit decorator wraps the inner request so every gated
* hold emits its pending event from one place. Pass-through: callers see the
* hold exactly as the inner returns it.
*/
export const requestApproval = auditRequestApproval(requestApprovalInner);
@@ -41,7 +41,6 @@ import { finalizeReject } from './finalize.js';
import { registerApprovalHandler } from './primitive.js';
import { handleApprovalsResponse } from './response-handler.js';
function now(): string {
return new Date().toISOString();
}
+9 -1
View File
@@ -15,6 +15,7 @@
* The response handler is registered via core's `registerResponseHandler`;
* core iterates handlers and the first one to return `true` claims the response.
*/
import { runApprovedHandler } from './approvals.audit.js';
import { wakeContainer } from '../../container-runner.js';
import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js';
import type { ResponsePayload } from '../../response-registry.js';
@@ -112,7 +113,14 @@ async function handleRegisteredApproval(
const payload = JSON.parse(approval.payload);
try {
await handler({ session, payload, userId, approvalId: approval.approval_id, notify });
// runApprovedHandler wraps the invocation to emit the gated chain's
// terminal audit event; rethrows, so the catch below behaves as before.
await runApprovedHandler(
handler,
{ session, payload, userId, approvalId: approval.approval_id, notify },
approval,
session,
);
log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId });
} catch (err) {
log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err });
+4 -3
View File
@@ -27,6 +27,7 @@ import {
setSenderScopeGate,
type AccessGateResult,
} from '../../router.js';
import { auditChannelDecision, auditChannelNameInterceptor, auditSenderDecision } from './permissions.audit.js';
import type { InboundEvent } from '../../channels/adapter.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
import { getDeliveryAdapter } from '../../delivery.js';
@@ -309,7 +310,7 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<S
return { claimed: true, decision };
}
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
registerResponseHandler(auditSenderDecision(handleSenderApprovalResponse));
// ── Unknown-channel registration flow ──
@@ -567,7 +568,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
};
}
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
registerResponseHandler(auditChannelDecision(handleChannelApprovalResponse));
// ── Free-text name interceptor ──
// Captures the next DM from an approver who clicked "Create new agent",
@@ -693,4 +694,4 @@ async function channelNameInterceptor(event: InboundEvent): Promise<ChannelAppro
return { claimed: true, decision: { kind: 'connected', ...decisionBase } };
}
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
registerMessageInterceptor(auditChannelNameInterceptor(channelNameInterceptor));
@@ -1,5 +1,5 @@
/**
* Permissions audit adapter (installed by /add-audit) senders.allow and
* Permissions audit adapter 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