Compare commits

..

40 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 6c46b1e43d Merge branch 'main' into cleanup/arch-scheduling-provider-docs 2026-07-04 19:54:49 +03:00
gavrielc 71453707ba Apply suggestion from @gavrielc 2026-07-04 19:54:15 +03:00
gavrielc 0aa9e668f6 Apply suggestion from @gavrielc 2026-07-04 19:53:24 +03:00
gavrielc 023128def5 Merge pull request #2946 from nanocoai/cleanup/remove-env-secrets-mirror
Remove the dead data/env/env secrets mirror
2026-07-04 19:52:00 +03:00
gavrielc c4a1679666 Merge branch 'main' into cleanup/remove-env-secrets-mirror 2026-07-04 19:51:30 +03:00
gavrielc 2e40e17155 Merge pull request #2945 from nanocoai/cleanup/security-docs-v2
Rewrite the security docs to match the v2 perimeter
2026-07-04 19:51:10 +03:00
gavrielc f896caefa0 Merge branch 'main' into cleanup/security-docs-v2 2026-07-04 19:50:59 +03:00
gavrielc 55c003c5f4 Apply suggestion from @gavrielc 2026-07-04 19:50:43 +03:00
gavrielc 20f2bae5cd Apply suggestion from @gavrielc 2026-07-04 19:50:10 +03:00
gavrielc d6b82e6473 Apply suggestion from @gavrielc 2026-07-04 19:47:01 +03:00
gavrielc fea5ac5f2c Apply suggestion from @gavrielc 2026-07-04 19:45:37 +03:00
gavrielc 2b86bbef19 Apply suggestion from @gavrielc 2026-07-04 19:45:04 +03:00
gavrielc 9dc0a7e62f Apply suggestion from @gavrielc 2026-07-04 19:44:33 +03:00
gavrielc 2035033397 Delete docs/docker-sandboxes.md 2026-07-04 19:44:00 +03:00
gavrielc 1c294ff9a5 Delete docs/APPLE-CONTAINER-NETWORKING.md 2026-07-04 19:43:27 +03:00
github-actions[bot] 43198310e1 chore: bump version to 2.1.36 2026-07-04 16:41:48 +00:00
gavrielc b7d6eebf4d Merge pull request #2943 from nanocoai/cleanup/mount-allowlist-readonly-and-cache
Mount allowlist: honor the readOnly key and stop caching parse errors
2026-07-04 19:41:35 +03:00
gavrielc 803f3413ec Merge branch 'main' into cleanup/mount-allowlist-readonly-and-cache 2026-07-04 19:41:26 +03:00
github-actions[bot] a8b7da7bcf docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:40:39 +00:00
github-actions[bot] 0b6ad5550d chore: bump version to 2.1.35 2026-07-04 16:40:37 +00:00
gavrielc c1965cfcaf Merge pull request #2942 from nanocoai/cleanup/a2a-batch-stamp-crossprocess
Fix the agent-to-agent in_reply_to stamp (cross-process no-op)
2026-07-04 19:40:23 +03:00
gavrielc 3cefbfccf4 Merge branch 'main' into cleanup/a2a-batch-stamp-crossprocess 2026-07-04 19:38:09 +03:00
github-actions[bot] 6f22c73aac docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:38:00 +00:00
gavrielc 31dd37b3a8 Merge pull request #2940 from nanocoai/cleanup/remove-onedb-shims
Delete one-DB-era @deprecated shims and dead exports
2026-07-04 19:37:47 +03:00
gavrielc 0835089a51 Fix stale architecture, scheduling, provider-config, and overlay docs
Correct docs (and one code comment) that describe systems that no longer
exist in v2: mark docs/SPEC.md as the historical v1 spec; replace the
impossible "write messages_in (to self)" / stale list_tasks scheduling
model with the real messages_out system-action path in
agent-runner-details.md and architecture.md; drop the false
MAX_CONCURRENT_CONTAINERS cap claim; remove the per-group agent-runner-src
overlay from the live DB map (source is a shared read-only mount) and fix
the insertTask attribution to src/modules/scheduling/db.ts; correct the
container-skills count to 8; repoint the deleted-doc comment in
src/claude-md-compose.ts; and replace the AGENT_PROVIDER / hand-edit
container.json provider-config instructions in add-opencode and add-mnemon
with the real `ncl groups config update --provider` flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:18:08 +03:00
gavrielc c82f062d57 Remove the dead data/env/env secrets mirror from setup
Nothing has read data/env/env since commit 1a07869 removed the container
mount, yet setup still wrote the full .env (live tokens included) there.
Drop every writer and correct the stale comments/docs that described it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:11:09 +03:00
gavrielc 3906104960 docs: rewrite security docs to match the v2 perimeter
Rewrite docs/SECURITY.md against the real v2 codepaths: the actual
buildMounts mount table, the allowReadWrite allowlist schema the mount
validator enforces, and the true defaults (egress open, no CPU/mem
limits, additional mounts blocked until an allowlist exists). Replace the
deleted v1 perimeter (main/non-main groups, ephemeral containers, IPC
authorization, /dev/null .env shadow, data/sessions path) with a v2 Trust
Model built on user_roles and long-lived per-session containers. Drop the
dangling /add-golden-registry reference. Mark docs/docker-sandboxes.md and
docs/APPLE-CONTAINER-NETWORKING.md as v1-historical and update the
docs/README.md portal rows. Remove the Apple Container native-runtime
claims from README.md (no runtime seam exists; container-runtime.ts
hardcodes docker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:10:46 +03:00
gavrielc ed9a3e330d Mount allowlist: honor the readOnly key and stop caching parse errors
Translate the per-root `readOnly` key (and tolerate the top-level
`nonMainReadOnly` key) that /manage-mounts and setup actually write, so
read-write grants are no longer silently forced read-only. Read+validate
the allowlist per call (mtime-keyed cache) instead of caching it — and its
parse errors — for the whole process lifetime. Add tests and fix the
skill docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:08:46 +03:00
gavrielc 102ce80fda Fix a2a in_reply_to stamp: publish through outbound.db, not module state
The active batch's inReplyTo lived in module-level state in current-batch.ts,
but the nanoclaw MCP server runs as a separate stdio subprocess from the poll
loop, so getCurrentInReplyTo() always read null there. The a2a reply stamp was
therefore dead — only the host peer-affinity fallback kept replies routing.

Publish the stamp through session_state in outbound.db (both processes already
open it): the poll loop writes it at batch start and clears it in the existing
finally; the MCP tools read it with an updated_at staleness guard so a stamp
left behind by a killed container isn't reused. Delete current-batch.ts and
reseed core.test.ts via the DB so it reflects the real process boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:49 +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
71 changed files with 3976 additions and 748 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;
};
}
+3 -3
View File
@@ -9,13 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
## Provider Compatibility
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider. The provider is the materialized `provider` key in each group's `container.json` (absent or `claude` = default Claude provider). Confirm it before applying:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
grep -H '"provider"' groups/*/container.json 2>/dev/null # no match, or "provider": "claude" = Claude
```
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
If a group sets a different provider (e.g. `"provider": "opencode"`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
## Phase 1: Pre-flight
+14 -7
View File
@@ -1,11 +1,11 @@
---
name: add-opencode
description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers.
description: Use OpenCode as an agent provider. OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per group via `ncl groups config update --provider opencode`; host passes OPENCODE_* and XDG mount when spawning containers.
---
# OpenCode agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`).
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected per agent group by the **`provider`** key in that group's `container.json` (materialized from the `container_configs` table) — set it with `ncl groups config update --provider opencode`. Default is `claude`.
Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image.
@@ -148,7 +148,7 @@ done
Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs.
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the group still needs `provider` set to `opencode` (see [Select the provider](#select-the-provider) below).
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
@@ -215,7 +215,7 @@ OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working.
**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
**Naming:** NanoClaw's **`provider: opencode`** (the `container.json` key, set via `ncl groups config update --provider opencode`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
**Host `.env` (typical Zen shape):**
@@ -236,9 +236,16 @@ onecli secrets create --name "OpenCode Zen" --type generic \
--header-name "x-api-key" --value-format "{value}"
```
### Per group / per session
### Select the provider
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
Per group, from the host:
```bash
ncl groups config update --id <group-id> --provider opencode
ncl groups restart --id <group-id>
```
`ncl groups config update --provider` writes the `provider` value into the `container_configs` table; the host materializes it into `groups/<folder>/container.json` at spawn time and the in-container runner reads `provider` from there (defaulting to `claude`). The restart picks up the change. Switching is an operator action — run it from the host. Memory does NOT carry over automatically between providers — run `/migrate-memory` to carry it across.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
@@ -250,6 +257,6 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
## Next Steps
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, switch a test group with `ncl groups config update --id <group-id> --provider opencode && ncl groups restart --id <group-id>`, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
To remove this provider, see [REMOVE.md](REMOVE.md).
+7 -9
View File
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
```
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
## Add Directories
Ask which directories the user wants agents to access. For each path:
- Validate the path exists
- Ask if it should be read-only for non-main agents (default: yes)
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
Build the JSON config and write it:
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
```
Use `--force` to overwrite the existing config.
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
```
## Reset to Empty
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
## After Changes
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automaticallyno service restart needed.
Run from your NanoClaw project root:
To apply the new config to a group that already has a running container, restart just that group:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
ncl groups restart --id <group-id>
```
+4 -4
View File
@@ -71,7 +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/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `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 |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
@@ -79,8 +79,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
@@ -182,7 +182,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
| Skill | When to Use |
|-------|-------------|
+2 -2
View File
@@ -80,7 +80,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
- **Web access** — search and fetch content from the web
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
@@ -165,7 +165,7 @@ Key files:
**Why Docker?**
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
**Can I run this on Linux or Windows?**
@@ -1,29 +0,0 @@
/**
* Per-batch context the poll loop publishes for downstream consumers
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
*
* Today the only field is `inReplyTo` the id of the first inbound
* message in the batch the agent is currently processing. MCP tools like
* `send_message` and `send_file` read this and stamp it onto the outbound
* row so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This is module-level state on purpose: the agent-runner is single-process
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
* before invoking the provider and `clearCurrentInReplyTo` after the batch
* completes (or errors out).
*/
let currentInReplyTo: string | null = null;
export function setCurrentInReplyTo(id: string | null): void {
currentInReplyTo = id;
}
export function clearCurrentInReplyTo(): void {
currentInReplyTo = null;
}
export function getCurrentInReplyTo(): string | null {
return currentInReplyTo;
}
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
export function clearContinuation(providerName: string): void {
deleteValue(continuationKey(providerName));
}
/**
* The a2a reply stamp: the id of the first inbound message in the batch the
* agent is currently processing. The poll loop publishes it at batch start;
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
* rows so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This lives in outbound.db rather than module state because the MCP server
* runs as a separate stdio subprocess from the poll loop module state set
* by the poll loop is invisible to it. Both processes open outbound.db
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
*/
const IN_REPLY_TO_KEY = 'current_in_reply_to';
/**
* Ignore a stamp older than this. The poll loop clears the stamp in a
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
* the guard stops a later out-of-batch read from picking up a dead stamp.
* Generous so a long-running batch's late sends still stamp correctly.
*/
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
export function setCurrentInReplyTo(id: string | null): void {
if (id === null) {
clearCurrentInReplyTo();
return;
}
setValue(IN_REPLY_TO_KEY, id);
}
export function clearCurrentInReplyTo(): void {
deleteValue(IN_REPLY_TO_KEY);
}
export function getCurrentInReplyTo(): string | null {
const row = getOutboundDb()
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
if (!row) return null;
const age = Date.now() - new Date(row.updated_at).getTime();
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
return row.value;
}
@@ -4,14 +4,31 @@
* batch in poll-loop, and outbound writes from MCP tools (send_message,
* send_file) must pick it up so a2a return-path routing on the host can
* correlate replies back to the originating session.
*
* The stamp is published through session_state in outbound.db, not module
* state the MCP server runs as a separate stdio subprocess from the poll
* loop, so it can only see the stamp through the shared DB. These tests seed
* it the same way the poll-loop process does (a direct DB write) rather than
* via any in-memory helper, so they exercise the real process boundary.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
import { getUndeliveredMessages } from '../db/messages-out.js';
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
import { sendMessage } from './core.js';
/**
* Publish the a2a reply stamp the way the poll loop does: a direct write to
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
* staleness guard MCP tools apply when reading it.
*/
function publishInReplyTo(id: string, ageMs = 0): void {
const updatedAt = new Date(Date.now() - ageMs).toISOString();
getOutboundDb()
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.run('current_in_reply_to', id, updatedAt);
}
beforeEach(() => {
initTestSessionDb();
// Seed a peer agent destination
@@ -24,13 +41,12 @@ beforeEach(() => {
});
afterEach(() => {
clearCurrentInReplyTo();
closeSessionDb();
});
describe('send_message MCP tool — in_reply_to plumbing', () => {
it('stamps current batch in_reply_to on outbound rows', async () => {
setCurrentInReplyTo('inbound-msg-1');
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
publishInReplyTo('inbound-msg-1');
await sendMessage.handler({ to: 'peer', text: 'hello' });
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
});
it('writes null when no batch is active', async () => {
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
// Nothing published to session_state — simulates ad-hoc / out-of-batch invocation.
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBeNull();
});
it('ignores a stale stamp left behind by a killed container', async () => {
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
+1 -1
View File
@@ -9,9 +9,9 @@
import fs from 'fs';
import path from 'path';
import { getCurrentInReplyTo } from '../current-batch.js';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getCurrentInReplyTo } from '../db/session-state.js';
import { getSessionRouting } from '../db/session-routing.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';
+7 -2
View File
@@ -2,8 +2,13 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
import {
clearContinuation,
clearCurrentInReplyTo,
migrateLegacyContinuation,
setContinuation,
setCurrentInReplyTo,
} from './db/session-state.js';
import {
formatMessages,
extractRouting,
-90
View File
@@ -1,90 +0,0 @@
# Apple Container Networking Setup (macOS 26)
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
## Quick Setup
Run these two commands (requires `sudo`):
```bash
# 1. Enable IP forwarding so the host routes container traffic
sudo sysctl -w net.inet.ip.forwarding=1
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
```
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
## Making It Persistent
These settings reset on reboot. To make them permanent:
**IP Forwarding** — add to `/etc/sysctl.conf`:
```
net.inet.ip.forwarding=1
```
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
```
nat on en0 from 192.168.64.0/24 to any -> (en0)
```
Then reload: `sudo pfctl -f /etc/pf.conf`
## IPv6 DNS Issue
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
The container image and runner are configured to prefer IPv4 via:
```
NODE_OPTIONS=--dns-result-order=ipv4first
```
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
## Verification
```bash
# Check IP forwarding is enabled
sysctl net.inet.ip.forwarding
# Expected: net.inet.ip.forwarding: 1
# Test container internet access
container run --rm --entrypoint curl nanoclaw-agent:latest \
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
# Expected: 404
# Check bridge interface (only exists when a container is running)
ifconfig bridge100
```
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
## How It Works
```
Container VM (192.168.64.x)
├── eth0 → gateway 192.168.64.1
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
├── IP forwarding (sysctl) routes packets from bridge100 → en0
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
en0 (your WiFi/Ethernet) → Internet
```
## References
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
-3
View File
@@ -6,8 +6,5 @@ The files in this directory are original design documents and developer referenc
| This directory | Documentation site |
|---|---|
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
+106 -60
View File
@@ -1,70 +1,106 @@
# 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 /
admin, global or scoped to an agent group) plus `agent_group_members` (the
unprivileged access gate).
| Entity | Trust Level | Rationale |
|--------|-------------|-----------|
| Main group | Trusted | Private self-chat, admin control |
| Non-main groups | Untrusted | Other users may be malicious |
| Container agents | Sandboxed | Isolated execution environment |
| Incoming messages | User input | Potential prompt injection |
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
## Security Boundaries
### 1. Container Isolation (Primary Boundary)
Agents execute in containers (lightweight Linux VMs), providing:
- **Process isolation** - Container processes cannot affect the host
- **Filesystem isolation** - Only explicitly mounted directories are visible
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
Agents execute in containers (Docker), providing:
- **Process isolation** — container processes cannot affect the host
- **Filesystem isolation** — only explicitly mounted directories are visible
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
This is the primary security boundary. Rather than relying on application-level
permission checks, the attack surface is limited by what's mounted.
### 2. Mount Security
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
- Outside project root
- Never mounted into containers
- Cannot be modified by agents
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
spawn. For the default (Claude) provider these are:
**Default Blocked Patterns:**
| Container path | Host source | Mode | Purpose |
|---|---|---|---|
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
| `/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 |
| `/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) |
| `/app/skills` | `container/skills/` | RO | Shared container skills |
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
**nested read-only mounts on top of the read-write group dir** — the agent can
read its config but cannot modify it. The project root is **never mounted**: the
container only ever sees the paths above plus any provider-contributed mounts
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
`package.json`) is not reachable.
**Additional-mount allowlist** — extra mounts from a group's container config
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
which is:
- Outside the project root
- Never mounted into containers
- Not modifiable by agents
Its schema:
```json
{
"allowedRoots": [
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
],
"blockedPatterns": ["password", "secret", "token"]
}
```
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
**Default blocked patterns** (merged with any in the file):
```
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
private_key, .secret
```
**Protections:**
- Symlink resolution before validation (prevents traversal attacks)
- Container path validation (rejects `..` and absolute paths)
- `nonMainReadOnly` option forces read-only for non-main groups
**Read-Only Project Root:**
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
**Enforcement** (`src/modules/mount-security/index.ts`):
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
### 3. Session Isolation
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
- Groups cannot see other groups' conversation history
- Session data includes full message history and file contents read
- Prevents cross-group information disclosure
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
(`.claude-shared`) and the working folder are scoped to the agent group, so:
- Different agent groups cannot see each other's conversation history or files.
- A group's sessions share that group's memory but keep separate message DBs.
### 4. IPC Authorization
This prevents cross-group information disclosure.
Messages and task operations are verified against group identity:
| Operation | Main Group | Non-Main Group |
|-----------|------------|----------------|
| Send message to own chat | ✓ | ✓ |
| Send message to other chats | ✓ | ✗ |
| Schedule task for self | ✓ | ✓ |
| Schedule task for others | ✓ | ✗ |
| View all tasks | ✓ | Own only |
| Manage other groups | ✓ | ✗ |
### 5. Credential Isolation (OneCLI Agent Vault)
### 4. Credential Isolation (OneCLI Agent Vault)
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
@@ -77,13 +113,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
**Per-agent policies:**
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
**NOT Mounted:**
- Channel auth sessions (`store/auth/`) — host only
- Mount allowlist — external, never mounted
- Any credentials matching blocked patterns
- `.env` is shadowed with `/dev/null` in the project root mount
**Never on the container filesystem:**
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
### 6. Egress Lockdown (Forced Proxy)
### 5. Egress Lockdown (Forced Proxy)
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
ignores it (or a raw socket) could reach the internet directly and bypass
@@ -111,31 +146,42 @@ no `host-gateway` route).
exception: a heal failure there is logged but not fatal, since already-running
agents stay on the internal net (no leak) until the gateway returns.
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
the agent reaches the OneCLI gateway over the host-gateway path and outbound
traffic is not confined to the internal network.
**Configuration:**
| Env | Default | Meaning |
| --- | --- | --- |
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
These variables are read from the **host process** environment (the service's
environment / `.env`), not from inside the container. The agent container is
started with only `TZ` and any provider-declared variables — host environment
variables, including secrets, are never forwarded into the agent.
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
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`.
## Privilege Comparison
## Resource Limits
| Capability | Main Group | Non-Main Group |
|------------|------------|----------------|
| Project root access | `/workspace/project` (ro) | None |
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
| Global memory | Implicit via project | `/workspace/global` (ro) |
| Additional mounts | Configurable | Read-only unless allowed |
| Network access | Unrestricted | Unrestricted |
| MCP tools | All | All |
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
agent is not throttled unless the operator configures a limit:
| Env | Default | Meaning |
| --- | --- | --- |
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
the host having no swap (a deployment concern). On a swapless host a runaway is
OOM-killed at the limit.
## Security Architecture Diagram
@@ -149,7 +195,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
┌──────────────────────────────────────────────────────────────────┐
│ HOST PROCESS (TRUSTED) │
│ • Message routing │
│ • IPC authorization
│ • Role / access checks (user_roles, agent_group_members)
│ • Mount validation (external allowlist) │
│ • Container lifecycle │
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
+2
View File
@@ -1,5 +1,7 @@
# NanoClaw Specification
> **⚠️ Historical v1 spec.** This document describes the original NanoClaw v1 architecture — the single `store/messages.db`, the file-based IPC watcher, the `task-scheduler.ts` loop, the `MAX_CONCURRENT_CONTAINERS` cap, and the `groups/{channel}_{name}/` folder convention. **None of these exist in v2.** v2 replaced them with the two-DB session split (`inbound.db`/`outbound.db`), the entity model (users → messaging groups → agent groups → sessions), and the system-action delivery path. Kept for reference only. For the current architecture start at [architecture.md](architecture.md) and the root [CLAUDE.md](../CLAUDE.md); the v1→v2 diff is in [v1-to-v2-changes.md](v1-to-v2-changes.md).
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
---
+6 -6
View File
@@ -596,7 +596,7 @@ Schedule a one-shot or recurring task.
}
```
Implementation: write a `messages_in` row (to self) with `kind: 'task'`, `process_after`, and optionally `recurrence`. The host sweep picks it up when due.
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts``insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
#### list_tasks
@@ -609,7 +609,7 @@ List active scheduled/recurring tasks.
}
```
Implementation: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`.
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
#### cancel_task / pause_task / resume_task / update_task
@@ -625,7 +625,7 @@ Modify a scheduled task.
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
```
Implementation: cancel/pause/resume update the live row(s) directly. update_task is sent as a system action — the host reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
#### register_agent_group
@@ -712,9 +712,9 @@ These are ephemeral to the container's lifetime. When the container is killed an
The agent-runner receives configuration via:
- **Environment variables:** `AGENT_PROVIDER` (claude/codex/opencode), `NANOCLAW_ADMIN_USER_ID`, provider-specific vars (API keys, model overrides), `TZ`
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
- **Optional startup config:** Some config may be passed as a JSON file at a fixed path (e.g., `/workspace/config.json`) for things like the session ID to resume, assistant name, and admin user ID. This avoids overloading environment variables.
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
@@ -731,7 +731,7 @@ function createProvider(name: ProviderName, config: ProviderConfig): AgentProvid
}
```
The provider name comes from the container's environment (`AGENT_PROVIDER` env var), set by the host based on `agent_groups.agent_provider` or `sessions.agent_provider`.
The provider name comes from the `provider` key in `/workspace/agent/container.json` (defaulting to `'claude'`), which the host materializes from the `container_configs` table — set it with `ncl groups config update --provider`. It is not an environment variable.
`ProviderConfig` contains provider-specific settings (API keys, model overrides, etc.) passed via environment variables — not via the interface. Each provider reads what it needs from `env`.
+8 -7
View File
@@ -1,5 +1,7 @@
# NanoClaw Architecture (Draft)
> **Draft — design intent, not a line-by-line spec.** Some passages predate the current implementation and can drift from it. The root [CLAUDE.md](../CLAUDE.md) and the cited source files (`src/`, `container/agent-runner/src/`) are the source of truth; when this doc and the code disagree, trust the code. Notably, scheduling MCP tools do **not** write `inbound.db` directly — they emit `messages_out` system actions that the host applies (see [agent-runner-details.md](agent-runner-details.md) and `src/modules/scheduling/`).
## Core Idea
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
@@ -128,7 +130,6 @@ Non-Chat-SDK channels (WhatsApp via Baileys, Gmail, custom integrations) impleme
The host is an orchestrator:
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
3. **Limits** — MAX_CONCURRENT_CONTAINERS caps active containers
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
@@ -244,7 +245,7 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
**Agent-runner creates schedules** by writing messages_in (to itself) or messages_out (reminders/notifications) with `process_after` and optionally `recurrence`.
**Agent-runner creates schedules** by emitting a `messages_out` row with `kind: 'system'` and an `action` (`schedule_task`, `cancel_task`, …) — it cannot write host-owned `inbound.db` directly. The host applies the action during delivery (`src/modules/scheduling/actions.ts`), inserting/updating the `kind: 'task'` `messages_in` row with `process_after` and optionally `recurrence`.
### messages_in content by kind
@@ -555,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
@@ -829,7 +830,7 @@ Mixed batches (e.g., a chat message + a system result both pending) are combined
### MCP Tools
MCP tools write directly to the session DB.
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, register a group) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
**Core tools:**
@@ -837,9 +838,9 @@ MCP tools write directly to the session DB.
|------|-------------|
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
| `schedule_task` | Write `messages_in` row (to self) with `process_after` + `recurrence`. Or `messages_out` with `deliver_after` for outbound reminders. |
| `list_tasks` | Query `messages_in WHERE recurrence IS NOT NULL` |
| `pause_task` / `resume_task` / `cancel_task` | Modify `messages_in` rows (update status, clear/set recurrence) |
| `schedule_task` | Write `messages_out`, `kind: 'system'`, `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
| `list_tasks` | Read `messages_in` (read-only mount) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
| `pause_task` / `resume_task` / `cancel_task` | Write `messages_out`, `kind: 'system'`, matching `action`; host updates the live `messages_in` row(s) |
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
**New tools:**
+2 -2
View File
@@ -17,7 +17,7 @@ data/v2-sessions/<agent_group_id>/<session_id>/
outbox/<message_id>/ ← attachments the agent produced
```
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`, `agent-runner-src/`) that is shared across every session of that agent group.
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`) that is shared across every session of that agent group. (The agent-runner source is not copied per group — it's a shared read-only mount from `container/agent-runner/src` into every container; see `src/container-runner.ts`.)
Path helpers in `src/session-manager.ts`: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()`.
@@ -55,7 +55,7 @@ CREATE INDEX idx_messages_in_series ON messages_in(series_id);
Content shapes: see [api-details.md §Session DB Schema Details](api-details.md#session-db-schema-details).
**Writers (host):** `insertMessage()`, `insertTask()`, `insertRecurrence()` — all in `src/db/session-db.ts`. Each calls `nextEvenSeq()`.
**Writers (host):** `insertMessage()` (and `nextEvenSeq()`) in `src/db/session-db.ts`; `insertTask()` and `insertRecurrence()` in `src/modules/scheduling/db.ts`. Each calls `nextEvenSeq()`.
**Reader (container):** `container/agent-runner/src/db/messages-in.ts` — polls `status='pending' AND (process_after IS NULL OR process_after <= now)`.
### 2.2 `delivered`
-1
View File
@@ -35,7 +35,6 @@ data/
v2-sessions/
<agent_group_id>/
.claude-shared/ ← shared Claude state for the agent group
agent-runner-src/ ← per-group agent-runner overlay
<session_id>/
inbound.db ← host writes, container reads
outbound.db ← container writes, host reads
-359
View File
@@ -1,359 +0,0 @@
# Running NanoClaw in Docker Sandboxes (Manual Setup)
This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
## Architecture
```
Host (macOS / Windows WSL)
└── Docker Sandbox (micro VM with isolated kernel)
├── NanoClaw process (Node.js)
│ ├── Channel adapters (WhatsApp, Telegram, etc.)
│ └── Container spawner → nested Docker daemon
└── Docker-in-Docker
└── nanoclaw-agent containers
└── Claude Agent SDK
```
Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary.
The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically.
> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies.
## Prerequisites
- **Docker Desktop v4.40+** with Sandbox support
- **Anthropic API key** (the sandbox proxy manages injection)
- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID
- For **WhatsApp**: a phone with WhatsApp installed
Verify sandbox support:
```bash
docker sandbox version
```
## Step 1: Create the Sandbox
On your host machine:
```bash
# Create a workspace directory
mkdir -p ~/nanoclaw-workspace
# Create a shell sandbox with the workspace mounted
docker sandbox create shell ~/nanoclaw-workspace
```
If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected:
```bash
docker sandbox network proxy shell-nanoclaw-workspace \
--bypass-host web.whatsapp.com \
--bypass-host "*.whatsapp.com" \
--bypass-host "*.whatsapp.net"
```
Telegram does not need proxy bypass.
Enter the sandbox:
```bash
docker sandbox run shell-nanoclaw-workspace
```
## Step 2: Install Prerequisites
Inside the sandbox:
```bash
sudo apt-get update && sudo apt-get install -y build-essential python3
npm config set strict-ssl false
```
## Step 3: Clone and Install NanoClaw
NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path.
```bash
# Clone to home first (virtiofs can corrupt git pack files during clone)
cd ~
git clone https://github.com/nanocoai/nanoclaw.git
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
WORKSPACE=/Users/you/nanoclaw-workspace
# Move into workspace so DinD mounts work
mv nanoclaw "$WORKSPACE/nanoclaw"
cd "$WORKSPACE/nanoclaw"
# Install dependencies
pnpm install
pnpm install https-proxy-agent
```
## Step 4: Apply Proxy and Sandbox Patches
NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions.
### 4a. Dockerfile — proxy args for container image build
`pnpm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`:
Add these lines after the `FROM` line:
```dockerfile
# Accept proxy build args
ARG http_proxy
ARG https_proxy
ARG no_proxy
ARG NODE_EXTRA_CA_CERTS
ARG npm_config_strict_ssl=true
RUN npm config set strict-ssl ${npm_config_strict_ssl}
```
And after the `RUN pnpm install` line:
```dockerfile
RUN npm config set strict-ssl true
```
### 4b. Build script — forward proxy args
Patch `container/build.sh` to pass proxy env vars to `docker build`:
Add these `--build-arg` flags to the `docker build` command:
```bash
--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
--build-arg npm_config_strict_ssl=false \
```
### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix
Three changes to `src/container-runner.ts`:
**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file:
```typescript
// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts)
const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
// Use emptyEnvPath instead of '/dev/null' in the mount
```
**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants.
**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers:
```typescript
const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
if (caCertSrc) {
const certDir = path.join(DATA_DIR, 'ca-cert');
fs.mkdirSync(certDir, { recursive: true });
fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
// Mount: certDir -> /workspace/ca-cert (read-only)
// Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container
}
```
### 4d. Container runtime — prevent self-termination
In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname:
```typescript
// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop
```
### 4e. Credential proxy — route through MITM proxy
In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests:
```typescript
import { HttpsProxyAgent } from 'https-proxy-agent';
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
// Pass upstreamAgent to https.request() options
```
### 4f. Setup script — proxy build args
Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b).
## Step 5: Build
```bash
pnpm run build
bash container/build.sh
```
## Step 6: Add a Channel
### Telegram
```bash
# Apply the Telegram skill
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-telegram
# Rebuild after applying the skill
pnpm run build
# Configure .env
cat > .env << EOF
TELEGRAM_BOT_TOKEN=<your-token-from-botfather>
ASSISTANT_NAME=nanoclaw
ANTHROPIC_API_KEY=proxy-managed
EOF
mkdir -p data/env && cp .env data/env/env
# Register your chat
pnpm exec tsx setup/index.ts --step register \
--jid "tg:<your-chat-id>" \
--name "My Chat" \
--trigger "@nanoclaw" \
--folder "telegram_main" \
--channel telegram \
--assistant-name "nanoclaw" \
--is-main \
--no-trigger-required
```
**To find your chat ID:** Send any message to your bot, then:
```bash
curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool
```
**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot.
**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild.
### WhatsApp
Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first.
```bash
# Apply the WhatsApp skill
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
# Rebuild
pnpm run build
# Configure .env
cat > .env << EOF
ASSISTANT_NAME=nanoclaw
ANTHROPIC_API_KEY=proxy-managed
EOF
mkdir -p data/env && cp .env data/env/env
# Authenticate (choose one):
# QR code — scan with WhatsApp camera:
pnpm exec tsx src/whatsapp-auth.ts
# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number:
pnpm exec tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus>
# Register your chat (JID = your phone number + @s.whatsapp.net)
pnpm exec tsx setup/index.ts --step register \
--jid "<phone>@s.whatsapp.net" \
--name "My Chat" \
--trigger "@nanoclaw" \
--folder "whatsapp_main" \
--channel whatsapp \
--assistant-name "nanoclaw" \
--is-main \
--no-trigger-required
```
**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild.
### Both Channels
Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately.
## Step 7: Run
```bash
pnpm start
```
You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically.
## Networking Details
### How the proxy works
All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`:
```
Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com
```
**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client.
### Shared paths for DinD mounts
Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared":
- `/dev/null` → replace with an empty file in the project dir
- `/usr/local/share/ca-certificates/` → copy cert to project dir
- `/home/agent/` → clone to workspace instead
### Git clone and virtiofs
The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace.
## Troubleshooting
### pnpm install fails with SELF_SIGNED_CERT_IN_CHAIN
```bash
npm config set strict-ssl false
```
### Container build fails with proxy errors
```bash
docker build \
--build-arg http_proxy=$http_proxy \
--build-arg https_proxy=$https_proxy \
-t nanoclaw-agent:latest container/
```
### Agent containers fail with "path not shared"
All bind-mounted paths must be under the workspace directory. Check:
- Is NanoClaw cloned into the workspace? (not `/home/agent/`)
- Is the CA cert copied to the project root?
- Has the empty `.env` shadow file been created?
### Agent containers can't reach Anthropic API
Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`.
### WhatsApp error 405
The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`.
### WhatsApp "Connection failed" immediately
Proxy bypass not configured. From the **host**, run:
```bash
docker sandbox network proxy <sandbox-name> \
--bypass-host web.whatsapp.com \
--bypass-host "*.whatsapp.com" \
--bypass-host "*.whatsapp.net"
```
### Telegram bot doesn't receive messages
1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`)
2. Check Group Privacy is disabled in @BotFather if using in groups
### Git clone fails with "inflate: data stream error"
Clone to a non-workspace path first, then move:
```bash
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
```
### WhatsApp QR code doesn't display
Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`):
```bash
docker sandbox run shell-nanoclaw-workspace
# Then inside:
pnpm exec tsx src/whatsapp-auth.ts
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.34",
"version": "2.1.38",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+1 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
#
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
# PUBLIC_KEY to .env, and restart the service. Non-interactive —
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
# server-invite step live in setup/channels/discord.ts. Credentials come in via
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
@@ -105,10 +105,6 @@ upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
+1 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
# Install the iMessage adapter, persist mode/creds to .env,
# and restart the service. Non-interactive — the Full Disk Access walkthrough
# (local mode) and Photon URL/key prompts (remote mode) live in
# setup/channels/imessage.ts. Creds come in via env vars:
@@ -135,10 +135,6 @@ else
remove_env IMESSAGE_ENABLED
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the creds…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
+1 -5
View File
@@ -2,7 +2,7 @@
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
# .env + data/env/env, and restart the service. Non-interactive — the
# .env, and restart the service. Non-interactive — the
# operator-facing app creation walkthrough + credential paste live in
# setup/channels/slack.ts. Credentials come in via env vars:
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
@@ -108,10 +108,6 @@ if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
+1 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
#
# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID /
# _TYPE to .env + data/env/env, and restart the service. Non-interactive —
# _TYPE to .env, and restart the service. Non-interactive —
# the operator-facing Azure portal walkthroughs live in
# setup/channels/teams.ts. Credentials come in via env vars:
# TEAMS_APP_ID (required)
@@ -114,10 +114,6 @@ if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
+1 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# Install the Telegram adapter, persist the bot token to .env + data/env/env,
# Install the Telegram adapter, persist the bot token to .env,
# restart the service, and open the bot's chat page in the local Telegram
# client. Non-interactive — the operator-facing "Create a bot" instructions
# and token paste live in setup/auto.ts. The token comes in via the
@@ -134,10 +134,6 @@ if echo "$INFO" | grep -q '"ok":true'; then
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
# surprise the user. Keeping it out of here means this script stays pure
+2 -6
View File
@@ -10,7 +10,7 @@
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
* a terminal QR the operator scans from Signal Linked Devices.
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
* 4. Persist SIGNAL_ACCOUNT to .env.
* 5. Kick the service so the adapter picks up the new credentials.
* 6. Ask operator role + agent name.
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
@@ -333,7 +333,7 @@ async function renderQr(url: string): Promise<string[]> {
}
}
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
/** Persist SIGNAL_ACCOUNT to .env. */
function writeSignalAccount(account: string): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
@@ -353,10 +353,6 @@ function writeSignalAccount(account: string): void {
}
fs.writeFileSync(envPath, contents);
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
setupLog.userInput('signal_account', account);
}
+1 -6
View File
@@ -433,7 +433,7 @@ async function askChatPhone(authedPhone: string): Promise<string> {
return phone;
}
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env and data/env/env. */
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env. */
function writeAssistantHasOwnNumber(): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
@@ -452,11 +452,6 @@ function writeAssistantHasOwnNumber(): void {
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
}
fs.writeFileSync(envPath, contents);
// Container reads from data/env/env.
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
}
async function resolveAgentName(): Promise<string> {
-9
View File
@@ -116,15 +116,6 @@ function main(): void {
channelsProcessed++;
}
// Sync to data/env/env
if (fs.existsSync(v2EnvPath)) {
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
try {
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
} catch { /* non-fatal */ }
}
console.log(`OK:channels=${channelsProcessed},env_keys=${envKeysCopied},files=${filesCopied}`);
if (missing.length > 0) {
console.log(`MISSING:${missing.join(',')}`);
-9
View File
@@ -65,15 +65,6 @@ function main(): void {
fs.writeFileSync(v2EnvPath, result);
}
// Sync to data/env/env (container reads from here)
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
try {
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
} catch {
// Non-fatal
}
console.log(`OK:copied=${copied.length},skipped=${skipped.length}`);
if (copied.length > 0) console.log(`COPIED:${copied.join(',')}`);
}
+2 -14
View File
@@ -1,10 +1,9 @@
/**
* Step: set-env Write or update a KEY=VALUE in .env, with optional sync to
* data/env/env (the container-mounted copy).
* Step: set-env Write or update a KEY=VALUE in .env.
*
* Usage:
* pnpm exec tsx setup/index.ts --step set-env -- \
* --key TELEGRAM_BOT_TOKEN --value "<token>" [--sync-container]
* --key TELEGRAM_BOT_TOKEN --value "<token>"
*
* Exists so channel-install flows don't have to invent grep/sed/rm pipelines
* (which can't be allowlisted tightly sed can read any file, and each
@@ -21,7 +20,6 @@ import { emitStatus } from './status.js';
export async function run(args: string[]): Promise<void> {
const keyIdx = args.indexOf('--key');
const valueIdx = args.indexOf('--value');
const syncContainer = args.includes('--sync-container');
if (keyIdx === -1 || !args[keyIdx + 1]) {
throw new Error('--key <KEY> is required');
@@ -59,19 +57,9 @@ export async function run(args: string[]): Promise<void> {
fs.writeFileSync(envFile, content);
log.info('Updated .env', { key, existed });
let synced = false;
if (syncContainer) {
const dataEnvDir = path.join(projectRoot, 'data', 'env');
fs.mkdirSync(dataEnvDir, { recursive: true });
fs.copyFileSync(envFile, path.join(dataEnvDir, 'env'));
synced = true;
log.info('Synced .env to container mount', { path: 'data/env/env' });
}
emitStatus('SET_ENV', {
KEY: key,
EXISTED: existed,
SYNCED_TO_CONTAINER: synced,
STATUS: 'success',
});
}
+1 -2
View File
@@ -11,8 +11,7 @@
*
* Runs on every spawn from `container-runner.buildMounts()`. Deterministic
* same inputs produce the same CLAUDE.md, and stale fragments are pruned.
*
* See `docs/claude-md-composition.md` for the full design.
* The composition order and fragment sources are documented inline above.
*/
import fs from 'fs';
import path from 'path';
+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 });
+119
View File
@@ -0,0 +1,119 @@
/**
* Tests for the mount allowlist loader/validator.
*
* Covers the two cleanups:
* - The loader honors the per-root `readOnly` key (translating it to
* `allowReadWrite`) and tolerates the top-level `nonMainReadOnly` key that
* setup writes into every fresh install.
* - The allowlist is read per call (mtime-keyed cache), so a parse error is
* never cached permanently a fixed file is picked up without a restart.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The config path is a module-level const in production; point it at a
// per-test temp file via a getter so each test is isolated from the cache.
const mockState = vi.hoisted(() => ({ allowlistPath: '' }));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual<Record<string, unknown>>('../../config.js');
return {
...actual,
get MOUNT_ALLOWLIST_PATH() {
return mockState.allowlistPath;
},
};
});
import { loadMountAllowlist, validateMount } from './index.js';
let tmpDir: string;
let configFile: string;
let projectsDir: string;
let repoDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mnt-sec-'));
configFile = path.join(tmpDir, 'mount-allowlist.json');
mockState.allowlistPath = configFile;
projectsDir = path.join(tmpDir, 'projects');
repoDir = path.join(projectsDir, 'repo');
fs.mkdirSync(repoDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeAllowlist(obj: unknown): void {
fs.writeFileSync(configFile, JSON.stringify(obj, null, 2) + '\n');
}
describe('loadMountAllowlist', () => {
it('translates per-root readOnly:false into a read-write grant', () => {
writeAllowlist({
allowedRoots: [{ path: projectsDir, readOnly: false }],
blockedPatterns: [],
});
const allowlist = loadMountAllowlist();
expect(allowlist).not.toBeNull();
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
// ...and a mount that requests read-write actually gets it.
const result = validateMount({ hostPath: repoDir, readonly: false });
expect(result.allowed).toBe(true);
expect(result.effectiveReadonly).toBe(false);
});
it('keeps readOnly:true as a read-only grant', () => {
writeAllowlist({
allowedRoots: [{ path: projectsDir, readOnly: true }],
blockedPatterns: [],
});
const allowlist = loadMountAllowlist();
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(false);
const result = validateMount({ hostPath: repoDir, readonly: false });
expect(result.allowed).toBe(true);
expect(result.effectiveReadonly).toBe(true);
});
it('tolerates an unknown top-level nonMainReadOnly key', () => {
writeAllowlist({
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
blockedPatterns: [],
nonMainReadOnly: true,
});
const allowlist = loadMountAllowlist();
expect(allowlist).not.toBeNull();
expect(allowlist!.allowedRoots).toHaveLength(1);
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
});
it('picks up a fixed file without a restart (parse errors are not cached)', () => {
// A broken edit blocks all mounts...
fs.writeFileSync(configFile, 'not valid json {');
expect(loadMountAllowlist()).toBeNull();
// ...but fixing the file recovers on the very next call — no restart.
writeAllowlist({
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
blockedPatterns: [],
});
const allowlist = loadMountAllowlist();
expect(allowlist).not.toBeNull();
expect(allowlist!.allowedRoots).toHaveLength(1);
});
it('returns null when the allowlist file is missing', () => {
// No file written.
expect(loadMountAllowlist()).toBeNull();
});
});
+81 -31
View File
@@ -29,9 +29,11 @@ export interface AllowedRoot {
description?: string;
}
// Cache the allowlist in memory - only reloads on process restart
let cachedAllowlist: MountAllowlist | null = null;
let allowlistLoadError: string | null = null;
// Cache the last successfully-parsed allowlist, keyed on the file's path +
// mtime. A changed or fixed file is picked up on the next call (no restart),
// and a parse error is never cached permanently — one bad edit blocks mounts
// only until the file is fixed.
let cache: { path: string; mtimeMs: number; allowlist: MountAllowlist } | null = null;
/**
* Default blocked patterns - paths that should never be mounted
@@ -57,60 +59,108 @@ const DEFAULT_BLOCKED_PATTERNS = [
];
/**
* Load the mount allowlist from the external config location.
* Returns null if the file doesn't exist or is invalid.
* Result is cached in memory for the lifetime of the process.
* Normalize a raw allowed-root entry into an {@link AllowedRoot}.
*
* The read-only decision is per-root. Historically this validator only read
* `allowReadWrite`, but the /manage-mounts skill and setup write `readOnly`
* instead so a `readOnly: false` grant was silently forced read-only.
* Translate `readOnly` `allowReadWrite = !readOnly` (with a warning) unless an
* explicit `allowReadWrite` is already present. With neither key, default to
* read-only (fail safe).
*/
export function loadMountAllowlist(): MountAllowlist | null {
if (cachedAllowlist !== null) {
return cachedAllowlist;
function normalizeRoot(root: Record<string, unknown>): AllowedRoot {
const rootPath = typeof root.path === 'string' ? root.path : '';
const description = typeof root.description === 'string' ? root.description : undefined;
let allowReadWrite: boolean;
if (typeof root.allowReadWrite === 'boolean') {
allowReadWrite = root.allowReadWrite;
} else if (typeof root.readOnly === 'boolean') {
allowReadWrite = !root.readOnly;
log.warn('Mount allowlist root uses "readOnly" — translating to allowReadWrite', {
root: rootPath,
readOnly: root.readOnly,
});
} else {
allowReadWrite = false;
}
if (allowlistLoadError !== null) {
// Already tried and failed, don't spam logs
return { path: rootPath, allowReadWrite, description };
}
/**
* Load the mount allowlist from the external config location.
* Returns null if the file doesn't exist or is invalid.
* Re-reads on every call, but serves from an in-memory cache while the file's
* mtime is unchanged. A parse error is never cached fix the file and the next
* call recovers without a service restart.
*/
export function loadMountAllowlist(): MountAllowlist | null {
// Missing-file behavior: warn and block additional mounts, but do NOT cache
// the miss — the file may be created later without a restart.
let stat: fs.Stats;
try {
stat = fs.statSync(MOUNT_ALLOWLIST_PATH);
} catch {
log.warn(
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
{ path: MOUNT_ALLOWLIST_PATH },
);
return null;
}
try {
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
// Do NOT cache this as an error — file may be created later without restart.
// Only parse/structural errors are permanently cached.
log.warn(
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
{ path: MOUNT_ALLOWLIST_PATH },
);
return null;
}
// Serve from cache only while the same file is unchanged since the last
// successful load. Any edit (including fixing a previously broken file) bumps
// the mtime and is picked up on the next call.
if (cache !== null && cache.path === MOUNT_ALLOWLIST_PATH && cache.mtimeMs === stat.mtimeMs) {
return cache.allowlist;
}
try {
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
const allowlist = JSON.parse(content) as MountAllowlist;
const raw = JSON.parse(content) as Record<string, unknown>;
// Validate structure
if (!Array.isArray(allowlist.allowedRoots)) {
if (!Array.isArray(raw.allowedRoots)) {
throw new Error('allowedRoots must be an array');
}
if (!Array.isArray(allowlist.blockedPatterns)) {
if (!Array.isArray(raw.blockedPatterns)) {
throw new Error('blockedPatterns must be an array');
}
// Merge with default blocked patterns
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
allowlist.blockedPatterns = mergedBlockedPatterns;
// Warn-and-ignore the top-level `nonMainReadOnly` key. Setup writes it into
// every fresh install, but this validator has no concept of a "main" agent —
// read-only is decided per-root. Do NOT throw: a hard reject would fail
// closed and brick all mounts on a standard install.
if ('nonMainReadOnly' in raw) {
log.warn('Mount allowlist has unsupported top-level "nonMainReadOnly" key — ignoring (read-only is per-root)', {
path: MOUNT_ALLOWLIST_PATH,
});
}
cachedAllowlist = allowlist;
const allowedRoots = (raw.allowedRoots as Array<Record<string, unknown>>).map(normalizeRoot);
// Merge with default blocked patterns
const blockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...(raw.blockedPatterns as string[])])];
const allowlist: MountAllowlist = { allowedRoots, blockedPatterns };
cache = { path: MOUNT_ALLOWLIST_PATH, mtimeMs: stat.mtimeMs, allowlist };
log.info('Mount allowlist loaded successfully', {
path: MOUNT_ALLOWLIST_PATH,
allowedRoots: allowlist.allowedRoots.length,
blockedPatterns: allowlist.blockedPatterns.length,
});
return cachedAllowlist;
return allowlist;
} catch (err) {
allowlistLoadError = err instanceof Error ? err.message : String(err);
// Do NOT poison the cache — a corrupt edit blocks mounts only until it's
// fixed, then the next call re-reads and recovers.
cache = null;
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
path: MOUNT_ALLOWLIST_PATH,
error: allowlistLoadError,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
+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);