Compare commits

...

31 Commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:20:13 +03:00
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 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 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
60 changed files with 3240 additions and 674 deletions
+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).
+1
View File
@@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **Opt-in local audit log.** `AUDIT_ENABLED=true` records every `ncl` command (both transports, including scope denials) and every host-routed approval — request, decision, and terminal outcome, covering CLI gates, self-mod, a2a message gates, agent creation (incl. the ungated global-scope door and the channel-registration name flow), the permissions sender/channel cards, and OneCLI credential holds — as one canonical SIEM-shaped event per action, appended to NDJSON day-files under `data/audit/`. Gated chains share a `correlation_id` (the approval id); details pass a recursive secret-key redactor and message-bearing events record shape only (never bodies). `AUDIT_RETENTION_DAYS` (default 90; `0` = keep forever) hard-deletes day-files past the horizon at boot + once daily in the host sweep. Read back with `ncl audit list [--actor --action --resource --outcome --since --until --correlation --limit] [--format ndjson]` — host + global-scope callers only. In-process consumers (future exporters) plug in via `registerAuditHook` — post-write hooks with an init/maintain/shutdown lifecycle that fire only after the local append succeeds, so external systems can never be ahead of the source of truth; no forwarder, credentials, or transport ships in core. Off by default: nothing is persisted until enabled, and an enabled box refuses to boot if `data/audit/` isn't writable. See [docs/SECURITY.md](docs/SECURITY.md#6-local-audit-log-opt-in).
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
+6 -4
View File
@@ -71,7 +71,8 @@ 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/audit/` | Opt-in local audit log (`AUDIT_ENABLED=true`): single emit seam + wrappers composed at module edges (dispatch middleware, `requestApproval` decorator, approval-resolved observer, permissions/OneCLI/create-agent seams), append-only NDJSON day-files under `data/audit/`, retention prune, `ncl audit` reader, and `registerAuditHook` — post-write hooks for in-process exporters (fire only after a successful local append). See the "Local Audit Log" section in [docs/SECURITY.md](docs/SECURITY.md) |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
@@ -79,8 +80,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. |
@@ -108,6 +109,7 @@ ncl help
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
| audit | list | Local audit log (read-only; host + global scope only; requires `AUDIT_ENABLED=true`; `--format ndjson` for export) |
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
@@ -182,7 +184,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?**
-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) |
+161 -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,97 @@ 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
### 6. Local Audit Log (Opt-In)
| 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 |
Every `ncl` command (both transports — host socket and container — including
denials) and every approval the host routes (request, decision, and terminal
outcome; covering CLI gates, self-mod, a2a message gates, agent creation, the
permissions sender/channel cards, and OneCLI credential holds) is recorded as
one canonical, SIEM-shaped JSON event.
- **Off by default.** Nothing is persisted until an operator sets
`AUDIT_ENABLED=true`; when disabled, the emitter no-ops and `data/audit/` is
never created. `ncl audit list` on a disabled box errors instead of returning
an empty list.
- **Store:** append-only NDJSON day-files under `data/audit/<UTC-day>.ndjson`,
written only by the host process. Retention is a hard delete — whole
day-files past the horizon are unlinked at boot and once daily in the host
sweep.
- **Fail-open + loud:** a failed append is logged and the action proceeds (a
full disk must not brick recovery commands). At boot, an enabled box refuses
to start if `data/audit/` isn't writable.
- **No secrets, no message bodies:** a recursive key mask
(`token|secret|key|password|credential|auth|bearer`) redacts details at the
single emit point, values are truncated to ~2 KB, and message-bearing events
(a2a gates, OneCLI body previews) record shape only — `body_chars` and
attachment names, never content.
- **Scope:** `ncl audit list` is available to host callers and global-scope
agents only. `audit` is not on the group-scope allowlist, so confined agents
are refused before any handler runs.
| Env | Default | Meaning |
| --- | --- | --- |
| `AUDIT_ENABLED` | `false` | Set `true` to record audit events. |
| `AUDIT_RETENTION_DAYS` | `90` | Days before day-files are unlinked; `0` = keep forever. Read only when enabled. |
Read back with `ncl audit list --actor … --action … --resource … --outcome …
--since 7d --correlation … --limit 100`; `--format ndjson` streams the stored
lines for SIEM export. Event fields are chosen to project losslessly onto
OCSF and Elastic ECS; forwarding is a mapping exercise, deferred until a
forwarder exists.
**Integration surfaces** (no push forwarder ships in core — credentials and
transport for external systems never live here):
1. **Tail the store** — any external agent (Vector, Filebeat, Fluent Bit, a
custom daemon) tails `data/audit/*.ndjson`; the format is stable and
`schema_version`-stamped.
2. **Pull via the CLI** — poll `ncl audit list --format ndjson --since …` and
dedupe on `event_id`.
3. **In-process post-write hooks** — a module (in-tree or skill-installed)
calls `registerAuditHook({ name, onEvent, init?, maintain?, shutdown? })`
from `src/audit/`. Hooks fire only **after** an event is durably appended
to the local day-file, so anything exported is guaranteed to exist in the
source of truth; a hook that misses events catches up by reading the
day-files (at-least-once). Hook failures are isolated and logged — they
never affect the log, other hooks, or the audited action.
## Resource Limits
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
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 +250,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.35",
"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',
});
}
+49
View File
@@ -0,0 +1,49 @@
/**
* emitAuditEvent the single opt-in check and the single append point.
*
* Only src/audit/ may call this (wrappers compose it at module edges;
* business logic never does): `grep emitAuditEvent src/` outside this
* directory must stay empty.
*/
import { randomUUID } from 'crypto';
import { AUDIT_ENABLED } from '../config.js';
import { log } from '../log.js';
import { 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 });
}
}
+200
View File
@@ -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);
});
});
+92
View File
@@ -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 });
}
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Opt-in local audit log (docs/SECURITY.md, "Local Audit Log").
*
* Everything composes through the wrappers exported here business logic
* contains zero audit calls. emitAuditEvent is deliberately NOT re-exported:
* only src/audit/ internals may call it.
*/
export * from './types.js';
export { redactDetails } from './redact.js';
export { AUDIT_DIR } from './store.js';
export { initAuditLog, maintainAudit } from './init.js';
export { type AuditHook, registerAuditHook } from './hooks.js';
export { runApprovedHandler, withAudit } from './wrappers.js';
+42
View File
@@ -0,0 +1,42 @@
/**
* Boot-time audit wiring one composition line in src/index.ts.
*
* The decision observer registers unconditionally (its emits no-op when
* disabled). When enabled: assert data/audit/ is writable (refusing to start
* beats running with a silent audit gap), 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 { registerAuditObserver } from './observer.js';
import { assertAuditWritable, AUDIT_DIR, markPrunedToday, pruneAuditLog, pruneAuditLogIfDue } from './store.js';
export function initAuditLog(): void {
registerAuditObserver();
if (!AUDIT_ENABLED) return;
try {
assertAuditWritable();
} 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();
}
+285
View File
@@ -0,0 +1,285 @@
/**
* Event assembly actors, origins, action names, resources, and the
* per-surface details rules (including shape-only for message-bearing
* payloads). Pure derivation; nothing here writes the log.
*/
import os from 'os';
import { getResource } from '../cli/crud.js';
import type { CallerContext, RequestFrame } from '../cli/frame.js';
import { type CommandDef, lookup } from '../cli/registry.js';
import { getMessagingGroup } from '../db/messaging-groups.js';
import type { Session } from '../types.js';
import { type AuditActor, type AuditEventInput, type AuditOrigin, type AuditResource, SYSTEM_ACTOR } from './types.js';
// ── Actors ──
function hostUser(): string {
try {
return os.userInfo().username;
// eslint-disable-next-line no-catch-all/no-catch-all -- os.userInfo throws on exotic hosts; a fallback actor id beats no audit event
} catch {
return process.env.USER || 'unknown';
}
}
/**
* Host callers stamp `host:<install user>` daemon-side: the ncl socket is
* 0600 and owned by the install user, so the identity is accurate by
* construction without peer credentials.
*/
export function actorForCaller(ctx: CallerContext): AuditActor {
return ctx.caller === 'host' ? { type: 'human', id: `host:${hostUser()}` } : { type: 'agent', id: ctx.agentGroupId };
}
/** Empty resolver id (sweep/timer paths) → the system actor. */
export function humanOrSystemActor(namespacedUserId: string): AuditActor {
return namespacedUserId ? { type: 'human', id: namespacedUserId } : SYSTEM_ACTOR;
}
// ── Origins ──
export function originForCaller(ctx: CallerContext): AuditOrigin {
if (ctx.caller === 'host') return { transport: 'socket' };
return containerOrigin(ctx.sessionId, ctx.messagingGroupId || null);
}
export function originForSession(session: Session): AuditOrigin {
return containerOrigin(session.id, session.messaging_group_id);
}
function containerOrigin(sessionId: string, messagingGroupId: string | null): AuditOrigin {
const origin: AuditOrigin = { transport: 'container', session_id: sessionId };
if (messagingGroupId) {
origin.messaging_group_id = messagingGroupId;
const channel = getMessagingGroup(messagingGroupId)?.channel_type;
if (channel) origin.channel = channel;
}
return origin;
}
/** Approval decisions arrive as card clicks on a chat platform. */
export function channelOriginForUser(namespacedUserId: string): AuditOrigin {
const idx = namespacedUserId.indexOf(':');
const channel = idx > 0 ? namespacedUserId.slice(0, idx) : undefined;
return channel ? { transport: 'channel', channel } : { transport: 'channel' };
}
// ── CLI resources ──
/**
* Frame-level args use `--hyphen-keys`; recorded details use the same
* underscore form the parsed handlers see. Mirrors crud's normalizeArgs
* (kept local so audit doesn't depend on a module tests commonly mock).
*/
export function normalizeArgKeys(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(raw)) {
out[k.replace(/-/g, '_')] = v;
}
return out;
}
/** CLI resource plural → audit resource type, where the singular isn't it. */
const RESOURCE_TYPE_OVERRIDES: Record<string, string> = {
groups: 'agent_group',
'messaging-groups': 'messaging_group',
'dropped-messages': 'dropped_message',
'user-dms': 'user_dm',
};
/**
* Derive touched/attempted resources from a command's effective args. Generic
* by design: `id` the command's own resource, group/user args their
* types, and a bare `{type}` entry when nothing else is known (a denied
* `users list` still names what was attempted).
*/
export function resourcesForCli(cmd: CommandDef, args: Record<string, unknown>): AuditResource[] {
if (!cmd.resource) return [];
const type = RESOURCE_TYPE_OVERRIDES[cmd.resource] ?? getResource(cmd.resource)?.name ?? cmd.resource;
const out: AuditResource[] = [];
const push = (t: string, id: unknown): void => {
if (typeof id !== 'string' || !id) return;
if (!out.some((r) => r.type === t && r.id === id)) out.push({ type: t, id });
};
push(type, args.id);
push('agent_group', args.agent_group_id ?? args.group);
push('user', args.user);
if (out.length === 0) out.push({ type });
return out;
}
// ── Approval-gated actions ──
/** Dotted audit action per approval type; cli_command derives from its frame. */
export const APPROVAL_AUDIT_ACTIONS: Record<string, string> = {
install_packages: 'self-mod.install-packages',
add_mcp_server: 'self-mod.add-mcp-server',
create_agent: 'agents.create',
a2a_message_gate: 'messages.a2a-gate',
onecli_credential: 'onecli.credential.use',
};
export function auditActionForApproval(approvalAction: string, payload: Record<string, unknown>): string {
if (approvalAction === 'cli_command') {
const frame = payload.frame as RequestFrame | undefined;
return (frame && lookup(frame.command)?.action) ?? 'cli.unknown-command';
}
return APPROVAL_AUDIT_ACTIONS[approvalAction] ?? approvalAction.replace(/_/g, '.');
}
/**
* details for an approval's pending/terminal events. Message-bearing payloads
* (a2a gate) record shape only body_chars and attachment names, never
* content; the emit-seam redactor is defense-in-depth, not the mechanism.
*/
export function detailsForApprovalPayload(
approvalAction: string,
payload: Record<string, unknown>,
): Record<string, unknown> {
switch (approvalAction) {
case 'cli_command': {
const frame = payload.frame as RequestFrame | undefined;
return { ...(frame?.args ?? {}) };
}
case 'create_agent': {
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
return { name: payload.name, instructions_chars: instructions ? instructions.length : 0 };
}
case 'a2a_message_gate': {
const { text, files } = messageShape(payload.content);
return { to: payload.platform_id, body_chars: text.length, attachments: files };
}
default:
// install_packages, add_mcp_server, and future types: metadata payloads
// pass through as-is — the emit-seam redactor masks sensitive keys.
return { ...payload };
}
}
export function resourcesForApproval(
approvalAction: string,
payload: Record<string, unknown>,
session: Session,
): AuditResource[] {
const base: AuditResource[] = [{ type: 'agent_group', id: session.agent_group_id }];
if (approvalAction === 'cli_command') {
const frame = payload.frame as RequestFrame | undefined;
const cmd = frame && lookup(frame.command);
if (cmd && frame) {
const cliResources = resourcesForCli(cmd, frame.args);
for (const r of cliResources) {
if (!base.some((b) => b.type === r.type && b.id === r.id)) base.push(r);
}
}
}
if (approvalAction === 'a2a_message_gate' && typeof payload.platform_id === 'string' && payload.platform_id) {
if (payload.platform_id !== session.agent_group_id) {
base.push({ type: 'agent_group', id: payload.platform_id });
}
}
return base;
}
// ── OneCLI credential approvals ──
/** = APPROVAL_AUDIT_ACTIONS.onecli_credential; named for the OneCLI wrappers. */
export const ONECLI_AUDIT_ACTION = 'onecli.credential.use';
interface OneCliRowShape {
approval_id: string;
agent_group_id?: string | null;
payload: string;
}
interface OneCliPayload {
oneCliRequestId?: string;
method?: string;
host?: string;
path?: string;
bodyPreview?: string;
agent?: { externalId?: string | null; name?: string };
approver?: string;
}
function oneCliPayload(row: OneCliRowShape): OneCliPayload {
try {
const parsed: unknown = JSON.parse(row.payload);
return parsed && typeof parsed === 'object' ? (parsed as OneCliPayload) : {};
// eslint-disable-next-line no-catch-all/no-catch-all -- stored payloads are untrusted; a malformed one must not break event assembly
} catch {
return {};
}
}
/** Shape-only: the request body's presence is auditable, its content never is. */
function oneCliDetails(p: OneCliPayload): Record<string, unknown> {
return {
method: p.method,
host: p.host,
path: p.path,
one_cli_request_id: p.oneCliRequestId,
body_preview_chars: typeof p.bodyPreview === 'string' ? p.bodyPreview.length : 0,
};
}
function oneCliResources(row: OneCliRowShape): AuditResource[] {
const out: AuditResource[] = [];
if (row.agent_group_id) out.push({ type: 'agent_group', id: row.agent_group_id });
out.push({ type: 'approval', id: row.approval_id });
return out;
}
/** Pending event for a OneCLI credential hold, derived from its row. */
export function oneCliHoldEvent(row: OneCliRowShape): AuditEventInput {
const p = oneCliPayload(row);
const resources = oneCliResources(row);
if (p.approver) resources.push({ type: 'user', id: p.approver });
return {
actor: { type: 'agent', id: row.agent_group_id ?? p.agent?.externalId ?? p.agent?.name ?? 'unknown' },
// OneCLI requests come from inside an agent container but carry no session.
origin: { transport: 'container' },
action: ONECLI_AUDIT_ACTION,
resources,
outcome: 'pending',
correlationId: row.approval_id,
details: oneCliDetails(p),
};
}
/** approvals.decide for a OneCLI resolution (click, expiry, or startup sweep). */
export function oneCliDecideEvent(
row: OneCliRowShape & { channel_type?: string | null },
args: { actor: AuditActor; origin: AuditOrigin; outcome: 'approved' | 'rejected'; reason?: string },
): AuditEventInput {
const details: Record<string, unknown> = {
gated_action: ONECLI_AUDIT_ACTION,
...oneCliDetails(oneCliPayload(row)),
};
if (args.reason) details.reason = args.reason;
return {
actor: args.actor,
origin: args.origin,
action: 'approvals.decide',
resources: oneCliResources(row),
outcome: args.outcome,
correlationId: row.approval_id,
details,
};
}
/** Mirror of agent-route's message-content parse — shape extraction only. */
function messageShape(content: unknown): { text: string; files: string[] } {
if (typeof content !== 'string') return { text: '', files: [] };
try {
const parsed = JSON.parse(content) as { text?: unknown; files?: unknown };
return {
text: typeof parsed.text === 'string' ? parsed.text : '',
files: Array.isArray(parsed.files) ? parsed.files.filter((f): f is string => typeof f === 'string') : [],
};
// eslint-disable-next-line no-catch-all/no-catch-all -- non-JSON content is the raw body; shape extraction must not throw
} catch {
return { text: content, files: [] };
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Decision events approvals.decide, one per resolved approval, riding the
* existing approval-resolved hook. Covers every requestApproval-backed action
* (cli_command, self-mod, create_agent, a2a gate); OneCLI never reaches
* notifyApprovalResolved and emits its decisions from its own wrappers.
*/
// Direct file import (not the approvals barrel) to keep the graph tight.
import { type ApprovalResolvedEvent, registerApprovalResolvedHandler } from '../modules/approvals/primitive.js';
import { emitAuditEvent } from './emit.js';
import { auditActionForApproval, channelOriginForUser, humanOrSystemActor, resourcesForApproval } from './mapping.js';
export function onApprovalResolved(event: ApprovalResolvedEvent): void {
emitAuditEvent(() => {
const payload = safeParse(event.approval.payload);
return {
// '' resolver = sweep/timer (e.g. the awaiting-reason ghost sweep) → system.
actor: humanOrSystemActor(event.userId),
// Decisions are card clicks on a chat platform, even system-finalized
// ones — the card lifecycle is the surface.
origin: channelOriginForUser(event.userId),
action: 'approvals.decide',
resources: [
...resourcesForApproval(event.approval.action, payload, event.session),
{ type: 'approval', id: event.approval.approval_id },
],
outcome: event.outcome === 'approve' ? 'approved' : 'rejected',
correlationId: event.approval.approval_id,
details: {
gated_action: auditActionForApproval(event.approval.action, payload),
requested_by: event.session.agent_group_id,
},
};
});
}
export function registerAuditObserver(): void {
registerApprovalResolvedHandler(onApprovalResolved);
}
function safeParse(json: string): Record<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 {};
}
}
+150
View File
@@ -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([]);
});
});
+160
View File
@@ -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,
}));
}
+69
View File
@@ -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);
});
});
+39
View File
@@ -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);
}
+183
View File
@@ -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);
});
});
+96
View File
@@ -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();
}
+64
View File
@@ -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' };
+287
View File
@@ -0,0 +1,287 @@
/**
* Out-of-band seam wrappers: permissions card decisions, the ungated
* create-agent door, and the four OneCLI paths. Inners are stubs each
* wrapper's contract is "call through, emit the right event (or none)".
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
const db = vi.hoisted(() => ({ row: undefined as Record<string, unknown> | undefined }));
vi.mock('../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
vi.mock('./store.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./store.js')>();
return {
...actual,
appendAuditLine: (line: string) => {
appended.lines.push(line);
},
};
});
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('../db/sessions.js', () => ({
getPendingApproval: () => db.row,
}));
vi.mock('../db/messaging-groups.js', () => ({
getMessagingGroup: () => undefined,
}));
import type { Session } from '../types.js';
import {
auditChannelDecision,
auditChannelNameInterceptor,
auditCreateAgentDirect,
auditOneCliDecision,
auditOneCliExpiry,
auditOneCliHold,
auditOneCliSweep,
auditSenderDecision,
} from './wrappers.js';
const PAYLOAD = {
questionId: 'q1',
value: 'approve',
userId: 'U1',
channelType: 'slack',
platformId: 'p',
threadId: null,
};
const SESSION: Session = {
id: 'sess-1',
agent_group_id: 'ag-parent',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: '2026-01-01T00:00:00.000Z',
};
const ONECLI_ROW = {
approval_id: 'oa-abc123',
agent_group_id: 'ag-1',
channel_type: 'telegram',
payload: JSON.stringify({
oneCliRequestId: 'uuid-1',
method: 'POST',
host: 'api.notion.com',
path: '/v1/pages',
bodyPreview: 'SECRET BODY CONTENT',
agent: { externalId: 'ag-1', name: 'Agent' },
approver: 'slack:U0ADMIN',
}),
};
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
appended.lines.length = 0;
db.row = undefined;
});
describe('auditSenderDecision', () => {
const decision = {
approved: true,
senderIdentity: 'slack:U0NEW',
agentGroupId: 'ag-1',
messagingGroupId: 'mg-1',
approverId: 'slack:U0ADMIN',
channelType: 'slack',
};
it('emits senders.allow success on approve and coerces claimed', async () => {
const wrapped = auditSenderDecision(async () => ({ claimed: true, decision }));
expect(await wrapped(PAYLOAD)).toBe(true);
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'human', id: 'slack:U0ADMIN' },
origin: { transport: 'channel', channel: 'slack' },
action: 'senders.allow',
outcome: 'success',
correlation_id: null,
details: {},
});
expect(event.resources).toEqual([
{ type: 'user', id: 'slack:U0NEW' },
{ type: 'agent_group', id: 'ag-1' },
{ type: 'messaging_group', id: 'mg-1' },
]);
});
it('emits rejected on deny', async () => {
const wrapped = auditSenderDecision(async () => ({ claimed: true, decision: { ...decision, approved: false } }));
await wrapped(PAYLOAD);
expect(events()[0].outcome).toBe('rejected');
});
it('emits nothing without a decision (unclaimed or unauthorized click)', async () => {
const unclaimed = auditSenderDecision(async () => ({ claimed: false }));
const unauthorized = auditSenderDecision(async () => ({ claimed: true }));
expect(await unclaimed(PAYLOAD)).toBe(false);
expect(await unauthorized(PAYLOAD)).toBe(true);
expect(appended.lines).toHaveLength(0);
});
});
describe('auditChannelDecision / auditChannelNameInterceptor', () => {
const base = { messagingGroupId: 'mg-1', approverId: 'slack:U0ADMIN', channelType: 'slack' };
it('maps connected → success with the wired agent group', async () => {
const wrapped = auditChannelDecision(async () => ({
claimed: true,
decision: { kind: 'connected' as const, agentGroupId: 'ag-1', createdAgentGroup: false, ...base },
}));
await wrapped(PAYLOAD);
const [event] = events();
expect(event).toMatchObject({
action: 'channels.register',
outcome: 'success',
details: { created_agent_group: false },
});
expect(event.resources).toEqual([
{ type: 'messaging_group', id: 'mg-1' },
{ type: 'agent_group', id: 'ag-1' },
]);
});
it('maps rejected and failed outcomes', async () => {
await auditChannelDecision(async () => ({ claimed: true, decision: { kind: 'rejected' as const, ...base } }))(
PAYLOAD,
);
await auditChannelDecision(async () => ({
claimed: true,
decision: { kind: 'failed' as const, reason: 'target agent group no longer exists', ...base },
}))(PAYLOAD);
const all = events();
expect(all[0]).toMatchObject({ action: 'channels.register', outcome: 'rejected' });
expect(all[1]).toMatchObject({ action: 'channels.register', outcome: 'failure' });
expect(all[1].details.reason).toContain('no longer exists');
});
it('records the interceptor-created agent group — the third creation door', async () => {
const wrapped = auditChannelNameInterceptor(async () => ({
claimed: true,
decision: {
kind: 'connected' as const,
agentGroupId: 'ag-new',
createdAgentGroup: true,
agentName: 'Scout',
...base,
},
}));
expect(await wrapped({} as never)).toBe(true);
const [event] = events();
expect(event.details).toMatchObject({ created_agent_group: true, agent_name: 'Scout' });
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-new' });
});
});
describe('auditCreateAgentDirect', () => {
it('emits agents.create success naming parent and child', async () => {
const wrapped = auditCreateAgentDirect(async () => ({
ok: true,
agentGroupId: 'ag-child',
name: 'Scout',
localName: 'scout',
folder: 'scout',
}));
const result = await wrapped('Scout', 'be helpful', SESSION, { id: 'ag-parent' } as never, () => {});
expect(result.ok).toBe(true);
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'agent', id: 'ag-parent' },
action: 'agents.create',
outcome: 'success',
correlation_id: null,
details: { name: 'Scout', parent: 'ag-parent', folder: 'scout', instructions_chars: 10 },
});
expect(event.resources).toEqual([
{ type: 'agent_group', id: 'ag-parent' },
{ type: 'agent_group', id: 'ag-child' },
]);
});
it('emits failure with the reason when creation is refused', async () => {
const wrapped = auditCreateAgentDirect(async () => ({ ok: false, reason: 'destination name collision' }));
await wrapped('Scout', null, SESSION, { id: 'ag-parent' } as never, () => {});
const [event] = events();
expect(event).toMatchObject({ outcome: 'failure', details: { reason: 'destination name collision' } });
});
});
describe('OneCLI wrappers', () => {
it('hold: emits pending from the row — shape only, never the body preview', () => {
const inner = vi.fn();
auditOneCliHold(inner)(ONECLI_ROW);
expect(inner).toHaveBeenCalledOnce();
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'agent', id: 'ag-1' },
origin: { transport: 'container' },
action: 'onecli.credential.use',
outcome: 'pending',
correlation_id: 'oa-abc123',
details: { method: 'POST', host: 'api.notion.com', path: '/v1/pages', body_preview_chars: 19 },
});
expect(event.resources).toContainEqual({ type: 'user', id: 'slack:U0ADMIN' });
expect(JSON.stringify(event)).not.toContain('SECRET BODY CONTENT');
});
it('decision: emits approvals.decide with the clicking human when resolved', () => {
db.row = ONECLI_ROW;
const wrapped = auditOneCliDecision(() => true);
expect(wrapped('oa-abc123', 'approve', 'slack:U0ADMIN')).toBe(true);
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'human', id: 'slack:U0ADMIN' },
origin: { transport: 'channel', channel: 'slack' },
action: 'approvals.decide',
outcome: 'approved',
correlation_id: 'oa-abc123',
details: { gated_action: 'onecli.credential.use' },
});
});
it('decision: emits nothing when the resolver is gone (inner returns false)', () => {
db.row = ONECLI_ROW;
expect(auditOneCliDecision(() => false)('oa-abc123', 'approve', 'slack:U0ADMIN')).toBe(false);
expect(appended.lines).toHaveLength(0);
});
it('expiry: the system actor rejects with the reason', async () => {
db.row = ONECLI_ROW;
await auditOneCliExpiry(async () => {})('oa-abc123', 'no response');
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'system', id: 'host' },
origin: { transport: 'channel', channel: 'telegram' },
outcome: 'rejected',
details: { reason: 'expired: no response' },
});
});
it('sweep: one system rejection per orphaned row', async () => {
const rows = [ONECLI_ROW, { ...ONECLI_ROW, approval_id: 'oa-second' }];
await auditOneCliSweep(
async () => {},
() => rows as never,
)();
const all = events();
expect(all).toHaveLength(2);
expect(all.map((e) => e.correlation_id)).toEqual(['oa-abc123', 'oa-second']);
expect(all.every((e) => e.actor.type === 'system' && e.details.reason === 'host restarted')).toBe(true);
});
});
+365
View File
@@ -0,0 +1,365 @@
/**
* Audit wrapper factories the only way audit events get emitted. Each
* instrumented seam composes one of these at its module edge; business logic
* stays free of audit calls.
*/
import type { InboundEvent } from '../channels/adapter.js';
import type { CallerContext, RequestFrame, ResponseFrame } from '../cli/frame.js';
import type { DispatchOptions, DispatchTrace } from '../cli/dispatch.js';
import { getPendingApproval } from '../db/sessions.js';
// Type-only imports from the approvals module — erased at runtime, so
// primitive.ts importing this file back is not a cycle.
import type {
ApprovalHandler,
ApprovalHandlerContext,
ApprovalHold,
RequestApprovalOptions,
} from '../modules/approvals/primitive.js';
import type { CreateAgentResult } from '../modules/agent-to-agent/create-agent.js';
import type { ResponsePayload } from '../response-registry.js';
import type { AgentGroup, PendingApproval, Session } from '../types.js';
import { emitAuditEvent } from './emit.js';
import {
actorForCaller,
auditActionForApproval,
channelOriginForUser,
detailsForApprovalPayload,
humanOrSystemActor,
normalizeArgKeys,
oneCliDecideEvent,
oneCliHoldEvent,
originForCaller,
originForSession,
resourcesForApproval,
resourcesForCli,
} from './mapping.js';
import { type AuditOutcome, type AuditResource, SYSTEM_ACTOR } from './types.js';
type DispatchInner = (req: RequestFrame, ctx: CallerContext, opts?: DispatchOptions) => Promise<ResponseFrame>;
/**
* Dispatch middleware the exported `dispatch` is the wrapped function, so
* the socket server, the container delivery-action, and the approved replay
* are all covered without changing a call site.
*
* Outcome derives from the response frame: ok success, forbidden denied
* (captures pre-handler scope denials), anything else failure.
* approval-pending responses are skipped the requestApproval decorator owns
* every pending event. On replays, opts.approvalId becomes the terminal
* event's correlation_id.
*/
export function withAudit(inner: DispatchInner): DispatchInner {
return async (req, ctx, opts = {}) => {
// Trace out-param: dispatch reassigns `req` internally (dash-id fallback,
// group auto-fill), so the resolved command + effective args surface here.
const trace: DispatchTrace = {};
const res = await inner(req, ctx, { ...opts, trace });
if (!res.ok && res.error.code === 'approval-pending') return res;
emitAuditEvent(() => {
const cmd = trace.cmd;
const args = normalizeArgKeys(trace.args ?? req.args);
const outcome: AuditOutcome = res.ok ? 'success' : res.error.code === 'forbidden' ? 'denied' : 'failure';
const details: Record<string, unknown> = { ...args };
if (!res.ok) {
details.error = res.error.code;
details.reason = res.error.message;
}
if (!cmd) details.command = trace.command ?? req.command;
return {
actor: actorForCaller(ctx),
origin: originForCaller(ctx),
action: cmd ? cmd.action : 'cli.unknown-command',
resources: cmd ? resourcesForCli(cmd, args) : [],
outcome,
correlationId: opts.approvalId ?? null,
details,
};
});
return res;
};
}
type RequestApprovalInner = (opts: RequestApprovalOptions) => Promise<ApprovalHold | null>;
/**
* requestApproval decorator owns every pending event, for all gated
* surfaces at once: only this seam holds the action, payload, minted approval
* id, and the approver the card actually went to. No hold (no approver, no DM
* target, delivery failure) no event.
*/
export function auditRequestApproval(inner: RequestApprovalInner): (opts: RequestApprovalOptions) => Promise<void> {
return async (opts) => {
const hold = await inner(opts);
if (!hold) return;
emitAuditEvent(() => ({
actor: { type: 'agent', id: opts.session.agent_group_id },
origin: originForSession(opts.session),
action: auditActionForApproval(opts.action, opts.payload),
resources: [
...resourcesForApproval(opts.action, opts.payload, opts.session),
{ type: 'approval', id: hold.approvalId },
// Who was asked: the picked approver is part of the record.
{ type: 'user', id: hold.approverUserId },
],
outcome: 'pending',
correlationId: hold.approvalId,
details: detailsForApprovalPayload(opts.action, opts.payload),
}));
};
}
/**
* Post-approve handler runner emits the terminal event of a gated chain
* (success/failure from whether the handler threw), correlated by the
* approval id. cli_command is skipped: its replay goes back through the
* dispatch middleware, which alone sees the real response frame. Rethrows so
* the response handler's own catch/notify behavior is unchanged.
*/
export async function runApprovedHandler(
handler: ApprovalHandler,
ctx: ApprovalHandlerContext,
approval: PendingApproval,
session: Session,
): Promise<void> {
const skip = approval.action === 'cli_command';
const terminal = (outcome: AuditOutcome, error?: string): void => {
if (skip) return;
emitAuditEvent(() => ({
actor: { type: 'agent', id: session.agent_group_id },
origin: originForSession(session),
action: auditActionForApproval(approval.action, ctx.payload),
resources: [
...resourcesForApproval(approval.action, ctx.payload, session),
{ type: 'approval', id: approval.approval_id },
],
outcome,
correlationId: approval.approval_id,
details: error
? { ...detailsForApprovalPayload(approval.action, ctx.payload), error }
: detailsForApprovalPayload(approval.action, ctx.payload),
}));
};
try {
await handler(ctx);
} catch (err) {
terminal('failure', err instanceof Error ? err.message : String(err));
throw err;
}
terminal('success');
}
// ── Permissions card handlers (senders.allow / channels.register) ──
// The inner handlers return domain descriptors; these adapters emit the event
// and coerce back to the boolean the response registry / router expect.
export interface SenderDecision {
approved: boolean;
senderIdentity: string;
agentGroupId: string;
messagingGroupId: string;
approverId: string;
channelType: string;
}
export type SenderApprovalResult = { claimed: boolean; decision?: SenderDecision };
export function auditSenderDecision(
inner: (payload: ResponsePayload) => Promise<SenderApprovalResult>,
): (payload: ResponsePayload) => Promise<boolean> {
return async (payload) => {
const result = await inner(payload);
const d = result.decision;
if (d) {
emitAuditEvent(() => ({
actor: { type: 'human', id: d.approverId },
origin: { transport: 'channel', channel: d.channelType || undefined },
action: 'senders.allow',
resources: [
{ type: 'user', id: d.senderIdentity },
{ type: 'agent_group', id: d.agentGroupId },
{ type: 'messaging_group', id: d.messagingGroupId },
],
outcome: d.approved ? 'success' : 'rejected',
// The withheld message is never read here — ids only.
details: {},
}));
}
return result.claimed;
};
}
export interface ChannelDecision {
kind: 'connected' | 'rejected' | 'failed';
messagingGroupId: string;
approverId: string;
channelType?: string;
agentGroupId?: string;
createdAgentGroup?: boolean;
agentName?: string;
reason?: string;
}
export type ChannelApprovalResult = { claimed: boolean; decision?: ChannelDecision };
function emitChannelDecision(d: ChannelDecision): void {
emitAuditEvent(() => {
const resources: AuditResource[] = [{ type: 'messaging_group', id: d.messagingGroupId }];
if (d.agentGroupId) resources.push({ type: 'agent_group', id: d.agentGroupId });
const details: Record<string, unknown> = {};
if (d.createdAgentGroup !== undefined) details.created_agent_group = d.createdAgentGroup;
if (d.agentName) details.agent_name = d.agentName;
if (d.reason) details.reason = d.reason;
return {
actor: { type: 'human', id: d.approverId },
origin: { transport: 'channel', channel: d.channelType || undefined },
action: 'channels.register',
resources,
outcome: d.kind === 'connected' ? 'success' : d.kind === 'rejected' ? 'rejected' : 'failure',
details,
};
});
}
export function auditChannelDecision(
inner: (payload: ResponsePayload) => Promise<ChannelApprovalResult>,
): (payload: ResponsePayload) => Promise<boolean> {
return async (payload) => {
const result = await inner(payload);
if (result.decision) emitChannelDecision(result.decision);
return result.claimed;
};
}
/** The free-text name reply — the third agent-creation door (new group + wiring). */
export function auditChannelNameInterceptor(
inner: (event: InboundEvent) => Promise<ChannelApprovalResult>,
): (event: InboundEvent) => Promise<boolean> {
return async (event) => {
const result = await inner(event);
if (result.decision) emitChannelDecision(result.decision);
return result.claimed;
};
}
// ── agents.create — the ungated door ──
type PerformCreateAgentFn = (
name: string,
instructions: string | null,
session: Session,
sourceGroup: AgentGroup,
notify: (text: string) => void,
) => Promise<CreateAgentResult>;
/**
* Wraps ONLY the global-scope direct call. The approval-gated path is covered
* by the pending/decide/terminal chain (runApprovedHandler wraps
* applyCreateAgent's run) wrapping the shared body would double-emit.
*/
export function auditCreateAgentDirect(inner: PerformCreateAgentFn): PerformCreateAgentFn {
return async (name, instructions, session, sourceGroup, notify) => {
const result = await inner(name, instructions, session, sourceGroup, notify);
emitAuditEvent(() => {
const resources: AuditResource[] = [{ type: 'agent_group', id: sourceGroup.id }];
const details: Record<string, unknown> = {
name,
parent: sourceGroup.id,
instructions_chars: instructions ? instructions.length : 0,
};
if (result.ok) {
resources.push({ type: 'agent_group', id: result.agentGroupId });
details.folder = result.folder;
} else {
details.reason = result.reason;
}
return {
actor: { type: 'agent', id: session.agent_group_id },
origin: originForSession(session),
action: 'agents.create',
resources,
outcome: result.ok ? 'success' : 'failure',
details,
};
});
return result;
};
}
// ── OneCLI credential approvals (hold + three resolution paths) ──
interface OneCliHoldRow {
approval_id: string;
agent_group_id?: string | null;
payload: string;
}
/** Wraps the hold's row insert — emits the pending event derived from the row. */
export function auditOneCliHold<T extends OneCliHoldRow>(inner: (row: T) => void): (row: T) => void {
return (row) => {
inner(row);
emitAuditEvent(() => oneCliHoldEvent(row));
};
}
/**
* Wraps the card-click resolver. The resolving human's id arrives here (the
* inner resolver has no use for it); the row is pre-read because the inner
* deletes it on resolution.
*/
export function auditOneCliDecision(
inner: (approvalId: string, selectedOption: string) => boolean,
): (approvalId: string, selectedOption: string, userId: string) => boolean {
return (approvalId, selectedOption, userId) => {
const row = getPendingApproval(approvalId);
const resolved = inner(approvalId, selectedOption);
if (resolved && row) {
emitAuditEvent(() =>
oneCliDecideEvent(row, {
actor: humanOrSystemActor(userId),
origin: channelOriginForUser(userId),
outcome: selectedOption === 'approve' ? 'approved' : 'rejected',
}),
);
}
return resolved;
};
}
/** Wraps the expiry timer's finalizer — the system actor rejects. */
export function auditOneCliExpiry(
inner: (approvalId: string, reason: string) => Promise<void>,
): (approvalId: string, reason: string) => Promise<void> {
return async (approvalId, reason) => {
const row = getPendingApproval(approvalId);
await inner(approvalId, reason);
if (row) {
emitAuditEvent(() =>
oneCliDecideEvent(row, {
actor: SYSTEM_ACTOR,
origin: { transport: 'channel', channel: row.channel_type ?? undefined },
outcome: 'rejected',
reason: `expired: ${reason}`,
}),
);
}
};
}
/** Wraps the startup sweep — one system rejection per orphaned row. */
export function auditOneCliSweep(inner: () => Promise<void>, listRows: () => PendingApproval[]): () => Promise<void> {
return async () => {
const rows = listRows();
await inner();
for (const row of rows) {
emitAuditEvent(() =>
oneCliDecideEvent(row, {
actor: SYSTEM_ACTOR,
origin: { transport: 'channel', channel: row.channel_type ?? undefined },
outcome: 'rejected',
reason: 'host restarted',
}),
);
}
};
}
+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,
+222
View File
@@ -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' });
});
});
+39 -7
View File
@@ -6,20 +6,36 @@
* Approval gating for risky calls from the container is the only branch
* that differs by caller. Host callers and `open` commands run inline.
*/
import { withAudit } from '../audit/wrappers.js';
import { getContainerConfig } from '../db/container-configs.js';
import { getAgentGroup } from '../db/agent-groups.js';
import { getSession } from '../db/sessions.js';
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { getResource } from './crud.js';
import { lookup } from './registry.js';
import { type CommandDef, lookup } from './registry.js';
type DispatchOptions = {
/** True when a command is being replayed after approval. */
approved?: boolean;
/**
* Out-param for the audit middleware: dispatch reassigns `req` internally
* (dash-joined id fallback, group-scope auto-fill), so the wrapper can't see
* the resolved command or effective args on the frame it passed in.
*/
export type DispatchTrace = {
cmd?: CommandDef;
command?: string;
args?: Record<string, unknown>;
};
export async function dispatch(
export type DispatchOptions = {
/** True when a command is being replayed after approval. */
approved?: boolean;
/** Approval id when replaying — becomes the terminal audit event's correlation_id. */
approvalId?: string;
/** Filled by dispatch for the audit middleware. */
trace?: DispatchTrace;
};
async function dispatchInner(
req: RequestFrame,
ctx: CallerContext,
opts: DispatchOptions = {},
@@ -48,6 +64,12 @@ export async function dispatch(
}
}
if (opts.trace) {
opts.trace.cmd = cmd;
opts.trace.command = req.command;
opts.trace.args = req.args;
}
if (!cmd) {
return err(req.id, 'unknown-command', `no command "${req.command}"`);
}
@@ -102,6 +124,7 @@ export async function dispatch(
fill.id = req.args.id ?? ctx.agentGroupId;
}
req = { ...req, args: { ...req.args, ...fill } };
if (opts.trace) opts.trace.args = req.args;
// Fail-closed pre-handler check for sessions-get: returns "not found"
// regardless of whether the UUID exists in another group, preventing an
@@ -192,10 +215,19 @@ export async function dispatch(
}
}
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
/**
* Public dispatcher the audit middleware wraps the inner dispatcher, so all
* three call sites (socket server, container delivery-action, and the
* approved replay below) emit audit events with zero caller changes.
*/
export const dispatch = withAudit(dispatchInner);
registerApprovalHandler('cli_command', async ({ payload, notify, approvalId }) => {
const frame = payload.frame as RequestFrame;
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
const response = await dispatch(frame, callerContext, { approved: true });
// approvalId threads through to the audit middleware: the replay's terminal
// event carries the gated chain's correlation_id, emitted there — not here.
const response = await dispatch(frame, callerContext, { approved: true, approvalId });
if (response.ok) {
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
+13 -2
View File
@@ -31,18 +31,29 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
* Custom operations return ad-hoc shapes and leave this undefined.
*/
generic?: 'list' | 'get';
/**
* Dotted audit action name, e.g. `groups.config.add-mcp-server`. Stamped
* explicitly by `registerResource` (which knows verb segment boundaries);
* hand-registered commands may omit it and get `name` with 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 {
+50
View File
@@ -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),
},
},
});
+1
View File
@@ -14,3 +14,4 @@ import './user-dms.js';
import './dropped-messages.js';
import './approvals.js';
import './sessions.js';
import './audit.js';
+10
View File
@@ -17,6 +17,8 @@ const envConfig = readEnvFile([
'NANOCLAW_EGRESS_LOCKDOWN',
'NANOCLAW_EGRESS_NETWORK',
'ONECLI_GATEWAY_CONTAINER',
'AUDIT_ENABLED',
'AUDIT_RETENTION_DAYS',
]);
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
@@ -64,6 +66,14 @@ export const EGRESS_NETWORK =
export const ONECLI_GATEWAY_CONTAINER =
process.env.ONECLI_GATEWAY_CONTAINER || envConfig.ONECLI_GATEWAY_CONTAINER || 'onecli';
// Local audit log — opt-in (docs/SECURITY.md, "Local Audit Log"). Off by
// default: the audit emitter no-ops and data/audit/ is never created.
export const AUDIT_ENABLED = (process.env.AUDIT_ENABLED || envConfig.AUDIT_ENABLED) === 'true';
// Audit day-files older than this many days are unlinked (a hard delete).
// 0 = keep forever. Read only when AUDIT_ENABLED=true.
const auditRetentionRaw = parseInt(process.env.AUDIT_RETENTION_DAYS || envConfig.AUDIT_RETENTION_DAYS || '90', 10);
export const AUDIT_RETENTION_DAYS = Number.isNaN(auditRetentionRaw) ? 90 : auditRetentionRaw;
// Timezone for scheduled tasks, message formatting, etc.
// Validates each candidate is a real IANA identifier before accepting.
function resolveConfigTimezone(): string {
+9 -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 {
+10
View File
@@ -43,6 +43,7 @@ import {
syncProcessingAcks,
type ContainerState,
} from './db/session-db.js';
import { maintainAudit } from './audit/index.js';
import { log } from './log.js';
import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbeatPath } from './session-manager.js';
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
@@ -164,6 +165,15 @@ async function sweep(): Promise<void> {
}
// MODULE-HOOK:approvals-reason-sweep:end
// Audit maintenance — retention prune (throttled to once per UTC day inside
// the module) + registered post-write hooks' maintain(). No-op when audit
// is disabled.
try {
maintainAudit();
} catch (err) {
log.error('Audit maintenance failed', { err });
}
setTimeout(sweep, SWEEP_INTERVAL_MS);
}
+5
View File
@@ -9,6 +9,7 @@ import path from 'path';
import { backfillContainerConfigs } from './backfill-container-configs.js';
import { DATA_DIR } from './config.js';
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
import { initAuditLog } from './audit/index.js';
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
import { initDb } from './db/connection.js';
import { runMigrations } from './db/migrations/index.js';
@@ -82,6 +83,10 @@ async function main(): Promise<void> {
// 1c. One-time filesystem cutover — idempotent, no-op after first run.
migrateGroupsToClaudeLocal();
// 1d. Audit log — registers the decision observer; when enabled, asserts
// data/audit/ is writable (throw → exit 1) and runs the boot prune.
initAuditLog();
// 2. Container runtime
ensureContainerRuntimeRunning();
cleanupOrphans();
+18 -4
View File
@@ -14,6 +14,7 @@
*/
import path from 'path';
import { auditCreateAgentDirect } from '../../audit/wrappers.js';
import { GROUPS_DIR } from '../../config.js';
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
import { getContainerConfig, updateContainerConfigScalars } from '../../db/container-configs.js';
@@ -75,7 +76,7 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
const cliScope = getContainerConfig(session.agent_group_id)?.cli_scope ?? 'group';
if (cliScope === 'global') {
// Trusted owner agent group — create directly, then notify (+wake) it.
await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
await performCreateAgentDirect(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
return;
}
@@ -112,6 +113,11 @@ export const applyCreateAgent: ApprovalHandler = async ({ session, payload, noti
await performCreateAgent(name, instructions, session, sourceGroup, notify);
};
/** What creation did — consumed by the ungated door's audit wrapper. */
export type CreateAgentResult =
| { ok: true; agentGroupId: string; name: string; localName: string; folder: string }
| { ok: false; reason: string };
/**
* Core creation: writes the new agent group + bidirectional destinations and
* scaffolds its filesystem, then reports via `notify`. Authorization is the
@@ -126,13 +132,13 @@ async function performCreateAgent(
session: Session,
sourceGroup: AgentGroup,
notify: (text: string) => void,
): Promise<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 +155,7 @@ async function performCreateAgent(
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
notify(`Cannot create agent "${name}": invalid folder path.`);
log.error('create_agent path traversal attempt', { folder, resolvedPath });
return;
return { ok: false, reason: 'invalid folder path' };
}
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -208,4 +214,12 @@ async function performCreateAgent(
notify(`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`);
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
return { ok: true, agentGroupId, name, localName, folder };
}
/**
* The ungated door: only the global-scope direct call is audit-wrapped. The
* approval path's terminal event comes from the wrapped handler run in the
* response handler wrapping the shared body would double-emit.
*/
const performCreateAgentDirect = auditCreateAgentDirect(performCreateAgent);
@@ -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 },
});
+24 -5
View File
@@ -19,6 +19,7 @@
*/
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from '../../audit/wrappers.js';
import { pickApprovalDelivery, pickApprover } from './primitive.js';
import { ONECLI_API_KEY, ONECLI_URL } from '../../config.js';
import { getAgentGroup } from '../../db/agent-groups.js';
@@ -64,8 +65,10 @@ function shortApprovalId(): string {
return `oa-${Math.random().toString(36).slice(2, 10)}`;
}
/** Called from the approvals response handler when a card button is clicked. */
export function resolveOneCLIApproval(approvalId: string, selectedOption: string): boolean {
/** Row insert for a hold — the audit wrapper emits the pending event from it. */
const recordOneCliHold = auditOneCliHold(createPendingApproval);
function resolveOneCLIApprovalInner(approvalId: string, selectedOption: string): boolean {
const state = pending.get(approvalId);
if (!state) return false;
pending.delete(approvalId);
@@ -82,6 +85,14 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string
return true;
}
/**
* Called from the approvals response handler when a card button is clicked.
* The audit wrapper records the decision with the clicking admin as actor
* (OneCLI rows never reach notifyApprovalResolved, so the shared observer
* can't cover them).
*/
export const resolveOneCLIApproval = auditOneCliDecision(resolveOneCLIApprovalInner);
export function startOneCLIApprovalHandler(deliveryAdapter: ChannelDeliveryAdapter): void {
if (handle) return;
adapterRef = deliveryAdapter;
@@ -170,7 +181,7 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
return 'deny';
}
createPendingApproval({
recordOneCliHold({
approval_id: approvalId,
session_id: null,
request_id: request.id,
@@ -214,7 +225,7 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
});
}
async function expireApproval(approvalId: string, reason: string): Promise<void> {
async function expireApprovalInner(approvalId: string, reason: string): Promise<void> {
const rows = getPendingApprovalsByAction(ONECLI_ACTION).filter((r) => r.approval_id === approvalId);
const row = rows[0];
if (!row) return;
@@ -225,6 +236,9 @@ async function expireApproval(approvalId: string, reason: string): Promise<void>
log.info('OneCLI approval expired', { approvalId, reason });
}
/** Timer-driven expiry — the audit wrapper records a system-actor rejection. */
const expireApproval = auditOneCliExpiry(expireApprovalInner);
async function editCardExpired(row: PendingApproval, reason: string): Promise<void> {
if (!adapterRef || !row.platform_message_id || !row.channel_type || !row.platform_id) return;
try {
@@ -244,7 +258,7 @@ async function editCardExpired(row: PendingApproval, reason: string): Promise<vo
}
}
async function sweepStaleApprovals(): Promise<void> {
async function sweepStaleApprovalsInner(): Promise<void> {
const rows = getPendingApprovalsByAction(ONECLI_ACTION);
if (rows.length === 0) return;
log.info('Sweeping stale OneCLI approvals from previous process', { count: rows.length });
@@ -254,6 +268,11 @@ async function sweepStaleApprovals(): Promise<void> {
}
}
/** Startup sweep — the audit wrapper records a system-actor rejection per row. */
const sweepStaleApprovals = auditOneCliSweep(sweepStaleApprovalsInner, () =>
getPendingApprovalsByAction(ONECLI_ACTION),
);
/** The hosted gateway's structured request summary not yet in the SDK's
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
* performed plus labeled fields (To / Subject / Body for email sends). */
@@ -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' });
});
});
+28 -4
View File
@@ -21,6 +21,9 @@
* exposing just user-roles/user-dms) is more churn than it's worth. Revisit
* if either module becomes genuinely optional (see REFACTOR_PLAN open q #3).
*/
// Direct file import (not the audit barrel): the barrel pulls in observer.ts,
// which imports this module — the narrow path keeps init order trivial.
import { auditRequestApproval } from '../../audit/wrappers.js';
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { createPendingApproval, getSession } from '../../db/sessions.js';
@@ -61,6 +64,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 +219,30 @@ export interface RequestApprovalOptions {
approverUserId?: string;
}
/**
* A successfully queued hold what the audit decorator needs to emit the
* pending event (the id is minted and the approver picked in here, invisible
* to callers otherwise). Null when no hold reached an approver.
*/
export interface ApprovalHold {
approvalId: string;
/** The approver the card was actually delivered to. */
approverUserId: string;
}
/**
* Queue an approval request. Picks an approver, delivers the card to their
* DM, and records the pending_approvals row. Fire-and-forget from the
* caller's perspective — the admin's response kicks off the registered
* approval handler for this action via the response dispatcher.
*/
export async function requestApproval(opts: RequestApprovalOptions): Promise<void> {
async function requestApprovalInner(opts: RequestApprovalOptions): Promise<ApprovalHold | null> {
const { session, action, payload, title, question, agentName, approverUserId } = opts;
const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id);
if (approvers.length === 0) {
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
return;
return null;
}
const originChannelType = session.messaging_group_id
@@ -236,7 +252,7 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<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 +288,17 @@ 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 };
}
/**
* Public export the audit decorator wraps the inner request so every gated
* hold emits its pending event from one place. Callers see Promise<void>
* semantics as before (the hold value is audit plumbing).
*/
export const requestApproval = auditRequestApproval(requestApprovalInner);
@@ -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),
}));
import { registerAuditObserver } from '../../audit/observer.js';
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createPendingApproval, createSession, getPendingApproval, getSession } from '../../db/sessions.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { upsertUser } from '../permissions/db/users.js';
import { finalizeReject } from './finalize.js';
import { registerApprovalHandler } from './primitive.js';
import { handleApprovalsResponse } from './response-handler.js';
registerAuditObserver();
function now(): string {
return new Date().toISOString();
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
appended.lines.length = 0;
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
createSession({
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: now(),
created_at: now(),
});
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
function seedApproval(id: string, action: string, payload: Record<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',
});
});
});
+10 -2
View File
@@ -15,6 +15,7 @@
* The response handler is registered via core's `registerResponseHandler`;
* core iterates handlers and the first one to return `true` claims the response.
*/
import { runApprovedHandler } from '../../audit/index.js';
import { wakeContainer } from '../../container-runner.js';
import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js';
import type { ResponsePayload } from '../../response-registry.js';
@@ -42,7 +43,7 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
}
if (approval.action === ONECLI_ACTION) {
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
if (resolveOneCLIApproval(payload.questionId, payload.value, namespacedUserId(payload) ?? '')) {
return true;
}
// Row exists but the in-memory resolver is gone (timer fired or the process
@@ -112,7 +113,14 @@ async function handleRegisteredApproval(
const payload = JSON.parse(approval.payload);
try {
await handler({ session, payload, userId, notify });
// runApprovedHandler wraps the invocation to emit the gated chain's
// terminal audit event; rethrows, so the catch below behaves as before.
await runApprovedHandler(
handler,
{ session, payload, userId, approvalId: approval.approval_id, notify },
approval,
session,
);
log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId });
} catch (err) {
log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err });
+69 -31
View File
@@ -27,6 +27,13 @@ import {
setSenderScopeGate,
type AccessGateResult,
} from '../../router.js';
import {
auditChannelDecision,
auditChannelNameInterceptor,
auditSenderDecision,
type ChannelApprovalResult,
type SenderApprovalResult,
} from '../../audit/wrappers.js';
import type { InboundEvent } from '../../channels/adapter.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
import { getDeliveryAdapter } from '../../delivery.js';
@@ -222,9 +229,9 @@ setSenderScopeGate(
* Deny: delete the row (no "deny list" a future message re-triggers a
* fresh card per ACTION-ITEMS item 5 "no denial persistence").
*/
async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<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 +250,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 +287,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 +297,10 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<b
approverId,
});
deletePendingSenderApproval(row.id);
return true;
return { claimed: true, decision };
}
registerResponseHandler(handleSenderApprovalResponse);
registerResponseHandler(auditSenderDecision(handleSenderApprovalResponse));
// ── Unknown-channel registration flow ──
@@ -307,9 +322,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 +339,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
clickerId,
expectedApprover: row.approver_user_id,
});
return true;
return { claimed: true };
}
const approverId = clickerId;
const decisionBase = {
messagingGroupId: row.messaging_group_id,
approverId,
channelType: payload.channelType,
};
// ── Reject / Cancel ──
if (payload.value === REJECT_VALUE) {
@@ -336,7 +356,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
messagingGroupId: row.messaging_group_id,
approverId,
});
return true;
return { claimed: true, decision: { kind: 'rejected', ...decisionBase } };
}
// ── Choose existing agent — send agent-selection follow-up card ──
@@ -347,11 +367,11 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
messagingGroupId: row.messaging_group_id,
approverUserId: row.approver_user_id,
});
return true;
return { claimed: true };
}
const adapter = getDeliveryAdapter();
if (!adapter) return true;
if (!adapter) return { claimed: true };
const agentGroups = getAllAgentGroups();
const options = buildAgentSelectionOptions(agentGroups, approverId);
@@ -378,7 +398,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
err,
});
}
return true;
return { claimed: true };
}
// ── Create new agent — prompt for free-text name ──
@@ -389,7 +409,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
messagingGroupId: row.messaging_group_id,
approverUserId: row.approver_user_id,
});
return true;
return { claimed: true };
}
const adapter = getDeliveryAdapter();
@@ -397,7 +417,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
log.error('Channel registration: no delivery adapter for name prompt', {
messagingGroupId: row.messaging_group_id,
});
return true;
return { claimed: true };
}
awaitingNameInput.set(row.approver_user_id, {
@@ -421,7 +441,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
});
awaitingNameInput.delete(row.approver_user_id);
}
return true;
return { claimed: true };
}
// ── Resolve target agent group (connect to existing or create new) ──
@@ -436,7 +456,10 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
targetAgentGroupId,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
return {
claimed: true,
decision: { kind: 'failed', reason: 'target agent group no longer exists', ...decisionBase },
};
}
if (!hasAdminPrivilege(approverId, targetAgentGroupId)) {
log.warn('Channel registration: target agent group rejected for unauthorized approver', {
@@ -444,14 +467,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
targetAgentGroupId,
approverId,
});
return true;
return { claimed: true };
}
} else {
log.warn('Channel registration: unknown response value', {
messagingGroupId: row.messaging_group_id,
value: payload.value,
});
return true;
return { claimed: true };
}
// ── Wire + replay (shared path for connect and create) ──
@@ -464,7 +487,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } };
}
const isGroup = event.threadId !== null;
@@ -512,22 +535,26 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
err,
});
}
return true;
return {
claimed: true,
decision: { kind: 'connected', agentGroupId: targetAgentGroupId, createdAgentGroup: false, ...decisionBase },
};
}
registerResponseHandler(handleChannelApprovalResponse);
registerResponseHandler(auditChannelDecision(handleChannelApprovalResponse));
// ── Free-text name interceptor ──
// Captures the next DM from an approver who clicked "Create new agent",
// creates the agent immediately, wires the channel, and replays.
registerMessageInterceptor(async (event: InboundEvent): Promise<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 +568,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 +581,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 +599,8 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
// The group WAS created above — the audit record must say so.
return { claimed: true, decision: { kind: 'failed', reason: 'failed to parse stored event', ...decisionBase } };
}
const isGroup = originalEvent.threadId !== null;
@@ -628,5 +664,7 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
.catch(() => {});
}
}
return true;
});
return { claimed: true, decision: { kind: 'connected', ...decisionBase } };
}
registerMessageInterceptor(auditChannelNameInterceptor(channelNameInterceptor));