mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd7515c1a5 | |||
| 4896887660 | |||
| b6cb53e21c | |||
| 5a7e75f854 | |||
| d770e56596 | |||
| 08a1ac9753 | |||
| 273489badf | |||
| 504651633f | |||
| 694ab74aa1 | |||
| aae81321e9 | |||
| 6c46b1e43d | |||
| 71453707ba | |||
| 0aa9e668f6 | |||
| 023128def5 | |||
| c4a1679666 | |||
| 2e40e17155 | |||
| f896caefa0 | |||
| 55c003c5f4 | |||
| 20f2bae5cd | |||
| d6b82e6473 | |||
| fea5ac5f2c | |||
| 2b86bbef19 | |||
| 9dc0a7e62f | |||
| 2035033397 | |||
| 1c294ff9a5 | |||
| 43198310e1 | |||
| b7d6eebf4d | |||
| 0835089a51 | |||
| c82f062d57 | |||
| 3906104960 | |||
| e3b2ffce36 |
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|-------|-------------|
|
||||
|
||||
@@ -80,7 +80,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
|
||||
@@ -165,7 +165,7 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
|
||||
@@ -1,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
|
||||
@@ -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
@@ -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) │
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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,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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(',')}`);
|
||||
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Post-write hook contract: hooks observe the LOG (fire only after a
|
||||
* successful append, exported ⊆ written), failures are isolated everywhere,
|
||||
* and the lifecycle (init/maintain/shutdown) behaves.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const state = vi.hoisted(() => ({ enabled: true, appendThrows: false, appended: [] as string[] }));
|
||||
|
||||
vi.mock('../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config.js')>();
|
||||
return {
|
||||
...actual,
|
||||
get AUDIT_ENABLED() {
|
||||
return state.enabled;
|
||||
},
|
||||
AUDIT_RETENTION_DAYS: 90,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-hooks-unused',
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./store.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./store.js')>();
|
||||
return {
|
||||
...actual,
|
||||
appendAuditLine: (line: string) => {
|
||||
if (state.appendThrows) throw new Error('disk full');
|
||||
state.appended.push(line);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
let hooks: typeof import('./hooks.js');
|
||||
let emit: typeof import('./emit.js');
|
||||
let log: (typeof import('../log.js'))['log'];
|
||||
|
||||
beforeEach(async () => {
|
||||
state.enabled = true;
|
||||
state.appendThrows = false;
|
||||
state.appended.length = 0;
|
||||
vi.resetModules(); // fresh hook registry per test
|
||||
hooks = await import('./hooks.js');
|
||||
emit = await import('./emit.js');
|
||||
log = (await import('../log.js')).log;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const EVENT_INPUT = {
|
||||
actor: { type: 'human' as const, id: 'host:test' },
|
||||
origin: { transport: 'socket' as const },
|
||||
action: 'groups.list',
|
||||
resources: [{ type: 'agent_group' }],
|
||||
outcome: 'success' as const,
|
||||
details: { limit: 5 },
|
||||
};
|
||||
|
||||
describe('post-write notification', () => {
|
||||
it('calls a registered hook with the parsed event and the exact stored line', () => {
|
||||
const seen: Array<{ event: import('./types.js').AuditEvent; line: string }> = [];
|
||||
hooks.registerAuditHook({ name: 'demo', onEvent: (event, line) => seen.push({ event, line }) });
|
||||
|
||||
emit.emitAuditEvent(EVENT_INPUT);
|
||||
|
||||
expect(state.appended).toHaveLength(1);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].line).toBe(state.appended[0]);
|
||||
expect(JSON.parse(seen[0].line)).toEqual(seen[0].event);
|
||||
expect(seen[0].event.action).toBe('groups.list');
|
||||
});
|
||||
|
||||
it('does NOT call hooks when the local append fails — exported ⊆ written', () => {
|
||||
const onEvent = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'demo', onEvent });
|
||||
state.appendThrows = true;
|
||||
|
||||
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow(); // action still proceeds
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Audit append failed'), expect.anything());
|
||||
});
|
||||
|
||||
it('does NOT call hooks when audit is disabled', () => {
|
||||
const onEvent = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'demo', onEvent });
|
||||
state.enabled = false;
|
||||
|
||||
emit.emitAuditEvent(EVENT_INPUT);
|
||||
|
||||
expect(state.appended).toHaveLength(0);
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isolates a throwing hook: the write survives, later hooks still run, the action proceeds', () => {
|
||||
const second = vi.fn();
|
||||
hooks.registerAuditHook({
|
||||
name: 'broken',
|
||||
onEvent: () => {
|
||||
throw new Error('exporter exploded');
|
||||
},
|
||||
});
|
||||
hooks.registerAuditHook({ name: 'healthy', onEvent: second });
|
||||
|
||||
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow();
|
||||
|
||||
expect(state.appended).toHaveLength(1); // the log has the event regardless
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Audit hook threw'),
|
||||
expect.objectContaining({ hook: 'broken', action: 'groups.list' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('initAuditHooks surfaces a failing init as a fatal error naming the hook', () => {
|
||||
hooks.registerAuditHook({ name: 'ok', onEvent: () => {}, init: vi.fn() });
|
||||
hooks.registerAuditHook({
|
||||
name: 'bad-boot',
|
||||
onEvent: () => {},
|
||||
init: () => {
|
||||
throw new Error('no route to collector');
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => hooks.initAuditHooks()).toThrow(/audit hook "bad-boot" failed to initialize.*no route/);
|
||||
});
|
||||
|
||||
it('maintainAuditHooks calls every maintain and isolates throws', () => {
|
||||
const m1 = vi.fn(() => {
|
||||
throw new Error('flush failed');
|
||||
});
|
||||
const m2 = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'a', onEvent: () => {}, maintain: m1 });
|
||||
hooks.registerAuditHook({ name: 'b', onEvent: () => {}, maintain: m2 });
|
||||
hooks.registerAuditHook({ name: 'c', onEvent: () => {} }); // no maintain — fine
|
||||
|
||||
expect(() => hooks.maintainAuditHooks()).not.toThrow();
|
||||
expect(m1).toHaveBeenCalledTimes(1);
|
||||
expect(m2).toHaveBeenCalledTimes(1);
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('maintenance failed'),
|
||||
expect.objectContaining({ hook: 'a' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shutdownAuditHooks awaits async shutdowns and isolates throws', async () => {
|
||||
const order: string[] = [];
|
||||
hooks.registerAuditHook({
|
||||
name: 'a',
|
||||
onEvent: () => {},
|
||||
shutdown: async () => {
|
||||
await Promise.resolve();
|
||||
order.push('a');
|
||||
},
|
||||
});
|
||||
hooks.registerAuditHook({
|
||||
name: 'b',
|
||||
onEvent: () => {},
|
||||
shutdown: () => {
|
||||
throw new Error('handle already closed');
|
||||
},
|
||||
});
|
||||
hooks.registerAuditHook({
|
||||
name: 'c',
|
||||
onEvent: () => {},
|
||||
shutdown: () => {
|
||||
order.push('c');
|
||||
},
|
||||
});
|
||||
|
||||
await hooks.shutdownAuditHooks();
|
||||
|
||||
expect(order).toEqual(['a', 'c']);
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('shutdown failed'),
|
||||
expect.objectContaining({ hook: 'b' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maintainAudit skips hook maintenance when audit is disabled', async () => {
|
||||
const init = await import('./init.js');
|
||||
const maintain = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'a', onEvent: () => {}, maintain });
|
||||
|
||||
state.enabled = false;
|
||||
init.maintainAudit();
|
||||
expect(maintain).not.toHaveBeenCalled();
|
||||
|
||||
state.enabled = true;
|
||||
init.maintainAudit();
|
||||
expect(maintain).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Post-write audit hooks — the in-process extension seam.
|
||||
*
|
||||
* A hook observes the audit LOG, not the event stream: `onEvent` fires only
|
||||
* after an event has been durably appended to the local day-file, so anything
|
||||
* a hook exports is guaranteed to exist in the source of truth
|
||||
* (exported ⊆ written). If the local append fails, hooks are not called —
|
||||
* and a hook that misses events (crash, restart) catches up by reading the
|
||||
* day-files, which is the at-least-once story.
|
||||
*
|
||||
* Registration follows the tree's observer idiom (registerApprovalResolvedHandler,
|
||||
* registerResponseHandler, …): an in-tree or skill-installed module calls
|
||||
* `registerAuditHook(...)` at import time — no core edits, and credentials or
|
||||
* transport for an external system live in that module, never here.
|
||||
*/
|
||||
import { log } from '../log.js';
|
||||
import type { AuditEvent } from './types.js';
|
||||
|
||||
export interface AuditHook {
|
||||
/** Short identifier used in logs and lifecycle errors. */
|
||||
name: string;
|
||||
/**
|
||||
* Called after a successful local append. `line` is the exact stored bytes
|
||||
* (one NDJSON line, no trailing newline); `event` is the parsed record.
|
||||
* MUST be fast and non-blocking — this runs on the audited action's call
|
||||
* path. A real exporter buffers here and does its IO from `maintain`/its own
|
||||
* timers. Throwing is tolerated: isolated and logged, never propagated.
|
||||
*/
|
||||
onEvent(event: AuditEvent, line: string): void;
|
||||
/** Boot hook, called only when audit is enabled. Throw = host refuses to start. */
|
||||
init?(): void;
|
||||
/** Periodic maintenance — called from the 60s host-sweep (enabled boxes only). */
|
||||
maintain?(): void;
|
||||
/** Graceful-shutdown hook (flush buffers, close handles). */
|
||||
shutdown?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
const hooks: AuditHook[] = [];
|
||||
|
||||
export function registerAuditHook(hook: AuditHook): void {
|
||||
hooks.push(hook);
|
||||
}
|
||||
|
||||
/** Fan out one written event to every hook, isolating failures per hook. */
|
||||
export function notifyAuditHooks(event: AuditEvent, line: string): void {
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
hook.onEvent(event, line);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- isolation is the contract: one bad hook must not affect the log, other hooks, or the audited action
|
||||
} catch (err) {
|
||||
log.error('Audit hook threw — event is safely in the log', { hook: hook.name, action: event.action, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot lifecycle. A hook that can't start is a silent-export-gap risk — fatal. */
|
||||
export function initAuditHooks(): void {
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
hook.init?.();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`audit hook "${hook.name}" failed to initialize: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sweep lifecycle — periodic maintenance, isolated per hook. */
|
||||
export function maintainAuditHooks(): void {
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
hook.maintain?.();
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- one hook's maintenance failure must not stop the others (or the sweep)
|
||||
} catch (err) {
|
||||
log.error('Audit hook maintenance failed', { hook: hook.name, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shutdown lifecycle — awaited by the host's graceful shutdown. */
|
||||
export async function shutdownAuditHooks(): Promise<void> {
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
await hook.shutdown?.();
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- shutdown must drain every hook even when one throws
|
||||
} catch (err) {
|
||||
log.error('Audit hook shutdown failed', { hook: hook.name, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const state = vi.hoisted(() => ({ dataDir: '', enabled: true }));
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
get DATA_DIR() {
|
||||
return state.dataDir;
|
||||
},
|
||||
get AUDIT_ENABLED() {
|
||||
return state.enabled;
|
||||
},
|
||||
AUDIT_RETENTION_DAYS: 90,
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
let reader: typeof import('./reader.js');
|
||||
let store: typeof import('./store.js');
|
||||
|
||||
beforeEach(async () => {
|
||||
state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-reader-'));
|
||||
state.enabled = true;
|
||||
vi.resetModules();
|
||||
store = await import('./store.js');
|
||||
reader = await import('./reader.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(state.dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function dayString(daysAgo: number): string {
|
||||
return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function isoAt(daysAgo: number, tag: number): string {
|
||||
const base = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
return `${base.toISOString().slice(0, 10)}T10:00:0${tag}.000Z`;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
function seedEvent(daysAgo: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
seq += 1;
|
||||
const event = {
|
||||
event_id: `e-${seq}`,
|
||||
time: isoAt(daysAgo, seq % 10),
|
||||
schema_version: 1,
|
||||
actor: { type: 'human', id: 'host:moshe', email: null, user_id: null, group_ids: null },
|
||||
origin: { transport: 'socket' },
|
||||
action: 'groups.list',
|
||||
resources: [{ type: 'agent_group', id: 'ag-1' }],
|
||||
outcome: 'success',
|
||||
correlation_id: null,
|
||||
details: {},
|
||||
...overrides,
|
||||
};
|
||||
const dir = path.join(state.dataDir, 'audit');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(path.join(dir, `${dayString(daysAgo)}.ndjson`), JSON.stringify(event) + '\n');
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('listAuditEvents', () => {
|
||||
it('throws the disabled error rather than returning an empty list', () => {
|
||||
state.enabled = false;
|
||||
expect(() => reader.listAuditEvents({})).toThrow('audit log is disabled — set AUDIT_ENABLED=true');
|
||||
});
|
||||
|
||||
it('returns flat rows newest-first across day-files, honoring --limit', () => {
|
||||
seedEvent(2, { event_id: 'old' });
|
||||
seedEvent(1, { event_id: 'mid-a' });
|
||||
seedEvent(1, { event_id: 'mid-b' });
|
||||
seedEvent(0, { event_id: 'new' });
|
||||
|
||||
const rows = reader.listAuditEvents({}) as Array<Record<string, unknown>>;
|
||||
expect(rows.map((r) => r.event_id)).toEqual(['new', 'mid-b', 'mid-a', 'old']);
|
||||
expect(rows[0]).toMatchObject({ actor: 'host:moshe', action: 'groups.list', outcome: 'success' });
|
||||
expect(rows[0].resources).toBe('agent_group:ag-1');
|
||||
|
||||
const limited = reader.listAuditEvents({ limit: 2 }) as Array<Record<string, unknown>>;
|
||||
expect(limited.map((r) => r.event_id)).toEqual(['new', 'mid-b']);
|
||||
});
|
||||
|
||||
it('filters by actor, outcome, correlation, and resource (id or type)', () => {
|
||||
seedEvent(0, { event_id: 'a', actor: { type: 'agent', id: 'ag-1' }, outcome: 'denied' });
|
||||
seedEvent(0, { event_id: 'b', correlation_id: 'appr-9', resources: [{ type: 'approval', id: 'appr-9' }] });
|
||||
seedEvent(0, { event_id: 'c', resources: [{ type: 'user', id: 'slack:U1' }] });
|
||||
|
||||
expect((reader.listAuditEvents({ actor: 'ag-1' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ outcome: 'denied' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ correlation: 'appr-9' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ resource: 'slack:U1' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ resource: 'approval' }) as unknown[]).length).toBe(1);
|
||||
});
|
||||
|
||||
it('matches actions exactly or by dotted prefix', () => {
|
||||
seedEvent(0, { event_id: 'cfg', action: 'groups.config.add-mcp-server' });
|
||||
seedEvent(0, { event_id: 'list', action: 'groups.list' });
|
||||
seedEvent(0, { event_id: 'other', action: 'sessions.list' });
|
||||
|
||||
expect((reader.listAuditEvents({ action: 'groups' }) as unknown[]).length).toBe(2);
|
||||
expect((reader.listAuditEvents({ action: 'groups.config' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ action: 'groups.list' }) as unknown[]).length).toBe(1);
|
||||
// Prefix means dotted segments, not substrings.
|
||||
expect((reader.listAuditEvents({ action: 'group' }) as unknown[]).length).toBe(0);
|
||||
});
|
||||
|
||||
it('applies --since/--until with relative and ISO forms', () => {
|
||||
seedEvent(5, { event_id: 'old' });
|
||||
seedEvent(0, { event_id: 'recent' });
|
||||
|
||||
const relative = reader.listAuditEvents({ since: '2d' }) as Array<Record<string, unknown>>;
|
||||
expect(relative.map((r) => r.event_id)).toEqual(['recent']);
|
||||
|
||||
const iso = reader.listAuditEvents({ until: dayString(2) }) as Array<Record<string, unknown>>;
|
||||
expect(iso.map((r) => r.event_id)).toEqual(['old']);
|
||||
|
||||
expect(() => reader.listAuditEvents({ since: 'yesterdayish' })).toThrow('invalid --since');
|
||||
});
|
||||
|
||||
it('--format ndjson returns the stored lines verbatim', () => {
|
||||
const seeded = seedEvent(0, { event_id: 'x1' });
|
||||
const out = reader.listAuditEvents({ format: 'ndjson' });
|
||||
expect(typeof out).toBe('string');
|
||||
expect(JSON.parse(out as string)).toEqual(seeded);
|
||||
expect(() => reader.listAuditEvents({ format: 'csv' })).toThrow('invalid --format');
|
||||
});
|
||||
|
||||
it('skips malformed stored lines and still returns the rest', () => {
|
||||
seedEvent(0, { event_id: 'good' });
|
||||
fs.appendFileSync(path.join(state.dataDir, 'audit', `${dayString(0)}.ndjson`), 'not-json\n');
|
||||
|
||||
const rows = reader.listAuditEvents({}) as Array<Record<string, unknown>>;
|
||||
expect(rows.map((r) => r.event_id)).toEqual(['good']);
|
||||
});
|
||||
|
||||
it('rejects an unknown --outcome value', () => {
|
||||
expect(() => reader.listAuditEvents({ outcome: 'meh' })).toThrow('invalid --outcome');
|
||||
});
|
||||
|
||||
it('returns empty when nothing has been recorded yet', () => {
|
||||
expect(reader.listAuditEvents({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Read-back for `ncl audit list` — a newest-first stream-scan over the
|
||||
* day-files. No index: fine at v1 volume, and adding one later doesn't change
|
||||
* the store. NDJSON export returns the stored lines verbatim.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { AUDIT_ENABLED } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
import { AUDIT_DIR, utcDay } from './store.js';
|
||||
import type { AuditEvent, AuditOutcome } from './types.js';
|
||||
|
||||
const OUTCOMES: ReadonlySet<string> = new Set(['success', 'failure', 'denied', 'pending', 'approved', 'rejected']);
|
||||
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export interface AuditQuery {
|
||||
actor?: string;
|
||||
/** Exact action or dotted prefix (`groups` matches `groups.config.update`). */
|
||||
action?: string;
|
||||
/** Matches any resources[] entry by id or by type. */
|
||||
resource?: string;
|
||||
outcome?: AuditOutcome;
|
||||
sinceMs?: number;
|
||||
untilMs?: number;
|
||||
correlation?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** `7d` / `24h` / `30m` relative to now, or an ISO date/datetime (UTC). */
|
||||
export function parseTimeFlag(value: string, flag: string): number {
|
||||
const rel = /^(\d+)([dhm])$/.exec(value);
|
||||
if (rel) {
|
||||
const n = Number(rel[1]);
|
||||
const unitMs = rel[2] === 'd' ? 86_400_000 : rel[2] === 'h' ? 3_600_000 : 60_000;
|
||||
return Date.now() - n * unitMs;
|
||||
}
|
||||
const abs = Date.parse(value);
|
||||
if (!Number.isNaN(abs)) return abs;
|
||||
throw new Error(`invalid ${flag} value "${value}" — use e.g. 7d, 24h, 30m, or an ISO date`);
|
||||
}
|
||||
|
||||
/** Newest first across files and within each file, up to q.limit. */
|
||||
export function queryAuditEvents(q: AuditQuery): { events: AuditEvent[]; lines: string[] } {
|
||||
const events: AuditEvent[] = [];
|
||||
const lines: string[] = [];
|
||||
let malformed = 0;
|
||||
|
||||
for (const { day, file } of dayFilesNewestFirst()) {
|
||||
if (events.length >= q.limit) break;
|
||||
// Whole-day skip: a file can't match a window its day lies outside.
|
||||
if (q.sinceMs !== undefined && day < utcDay(new Date(q.sinceMs))) continue;
|
||||
if (q.untilMs !== undefined && day > utcDay(new Date(q.untilMs))) continue;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf8');
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- a torn/pruned day-file must not fail the whole query
|
||||
} catch (err) {
|
||||
log.warn('Audit reader failed to read day-file', { file, err });
|
||||
continue;
|
||||
}
|
||||
const fileLines = content.split('\n').filter((l) => l.trim() !== '');
|
||||
// Lines within a file are chronological — walk backwards for newest-first.
|
||||
for (let i = fileLines.length - 1; i >= 0 && events.length < q.limit; i--) {
|
||||
let event: AuditEvent;
|
||||
try {
|
||||
event = JSON.parse(fileLines[i]) as AuditEvent;
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- malformed stored lines are skipped (counted + warned below)
|
||||
} catch {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
if (!matches(event, q)) continue;
|
||||
events.push(event);
|
||||
lines.push(fileLines[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (malformed > 0) {
|
||||
log.warn('Audit reader skipped malformed lines', { malformed });
|
||||
}
|
||||
return { events, lines };
|
||||
}
|
||||
|
||||
function dayFilesNewestFirst(): Array<{ day: string; file: string }> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(AUDIT_DIR);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means no events, not an error
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.map((e) => DAY_FILE_RE.exec(e))
|
||||
.filter((m): m is RegExpExecArray => m !== null)
|
||||
.map((m) => ({ day: m[1], file: path.join(AUDIT_DIR, m[0]) }))
|
||||
.sort((a, b) => (a.day < b.day ? 1 : -1));
|
||||
}
|
||||
|
||||
function matches(event: AuditEvent, q: AuditQuery): boolean {
|
||||
if (q.actor !== undefined && event.actor?.id !== q.actor) return false;
|
||||
if (q.action !== undefined && event.action !== q.action && !event.action?.startsWith(q.action + '.')) return false;
|
||||
if (q.outcome !== undefined && event.outcome !== q.outcome) return false;
|
||||
if (q.correlation !== undefined && event.correlation_id !== q.correlation) return false;
|
||||
if (q.resource !== undefined) {
|
||||
const hit = (event.resources ?? []).some((r) => r.id === q.resource || r.type === q.resource);
|
||||
if (!hit) return false;
|
||||
}
|
||||
const t = Date.parse(event.time ?? '');
|
||||
if (q.sinceMs !== undefined && !(t >= q.sinceMs)) return false;
|
||||
if (q.untilMs !== undefined && !(t <= q.untilMs)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ncl audit list` handler. Disabled → an explicit error: an empty list would
|
||||
* read as "no actions happened", which is a different truth than "not
|
||||
* recording". `--format ndjson` returns the stored lines verbatim (the human
|
||||
* formatter passes strings through); default returns flat rows for the table.
|
||||
*/
|
||||
export function listAuditEvents(args: Record<string, unknown>): string | Array<Record<string, unknown>> {
|
||||
if (!AUDIT_ENABLED) {
|
||||
throw new Error('audit log is disabled — set AUDIT_ENABLED=true');
|
||||
}
|
||||
|
||||
const format = args.format !== undefined ? String(args.format) : '';
|
||||
if (format && format !== 'ndjson') {
|
||||
throw new Error(`invalid --format "${format}" — only "ndjson" is supported`);
|
||||
}
|
||||
const outcome = args.outcome !== undefined ? String(args.outcome) : undefined;
|
||||
if (outcome !== undefined && !OUTCOMES.has(outcome)) {
|
||||
throw new Error(`invalid --outcome "${outcome}" — one of: ${[...OUTCOMES].join(', ')}`);
|
||||
}
|
||||
|
||||
const q: AuditQuery = {
|
||||
actor: args.actor !== undefined ? String(args.actor) : undefined,
|
||||
action: args.action !== undefined ? String(args.action) : undefined,
|
||||
resource: args.resource !== undefined ? String(args.resource) : undefined,
|
||||
outcome: outcome as AuditOutcome | undefined,
|
||||
correlation: args.correlation !== undefined ? String(args.correlation) : undefined,
|
||||
sinceMs: args.since !== undefined ? parseTimeFlag(String(args.since), '--since') : undefined,
|
||||
untilMs: args.until !== undefined ? parseTimeFlag(String(args.until), '--until') : undefined,
|
||||
limit: args.limit !== undefined ? Math.max(1, Number(args.limit) || DEFAULT_LIMIT) : DEFAULT_LIMIT,
|
||||
};
|
||||
|
||||
const { events, lines } = queryAuditEvents(q);
|
||||
if (format === 'ndjson') return lines.join('\n');
|
||||
|
||||
return events.map((e) => ({
|
||||
time: e.time,
|
||||
actor: e.actor?.id ?? '',
|
||||
action: e.action,
|
||||
resources: (e.resources ?? []).map((r) => (r.id ? `${r.type}:${r.id}` : r.type)).join(' '),
|
||||
outcome: e.outcome,
|
||||
correlation: e.correlation_id ?? '',
|
||||
event_id: e.event_id,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { redactDetails } from './redact.js';
|
||||
|
||||
describe('redactDetails', () => {
|
||||
it('masks sensitive keys at any depth, case-insensitively', () => {
|
||||
const out = redactDetails({
|
||||
name: 'notion',
|
||||
env: { NOTION_TOKEN: 'secret-value', SAFE_VALUE: 'ok' },
|
||||
nested: { Authorization: 'Bearer abc', list: [{ 'api-key': 'k' }, { plain: 'p' }] },
|
||||
password: 'hunter2',
|
||||
});
|
||||
expect(out).toEqual({
|
||||
name: 'notion',
|
||||
env: { NOTION_TOKEN: '[REDACTED]', SAFE_VALUE: 'ok' },
|
||||
nested: { Authorization: '[REDACTED]', list: [{ 'api-key': '[REDACTED]' }, { plain: 'p' }] },
|
||||
password: '[REDACTED]',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the documented key pattern (token|secret|key|password|credential|auth|bearer)', () => {
|
||||
const out = redactDetails({
|
||||
access_token: 'x',
|
||||
clientSecret: 'x',
|
||||
ssh_key: 'x',
|
||||
credentials: 'x',
|
||||
oauth_flow: 'x',
|
||||
bearerValue: 'x',
|
||||
username: 'moshe',
|
||||
});
|
||||
expect(Object.entries(out).filter(([, v]) => v === '[REDACTED]')).toHaveLength(6);
|
||||
expect(out.username).toBe('moshe');
|
||||
});
|
||||
|
||||
it('never recurses into a masked key — the whole value is replaced', () => {
|
||||
const out = redactDetails({ auth: { inner: 'visible?' } });
|
||||
expect(out.auth).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('truncates strings over 2 KB post-redaction', () => {
|
||||
const long = 'a'.repeat(5000);
|
||||
const out = redactDetails({ blob: long, short: 'b' });
|
||||
expect(out.blob).toBe('a'.repeat(2048) + '…[truncated]');
|
||||
expect(out.short).toBe('b');
|
||||
});
|
||||
|
||||
it('passes non-string scalars through untouched', () => {
|
||||
const out = redactDetails({ n: 42, b: true, z: null, u: undefined });
|
||||
expect(out).toEqual({ n: 42, b: true, z: null, u: undefined });
|
||||
});
|
||||
|
||||
it('caps depth (cycle guard) without throwing', () => {
|
||||
const cyclic: Record<string, unknown> = { a: 1 };
|
||||
cyclic.self = cyclic;
|
||||
const out = redactDetails(cyclic);
|
||||
let cursor: unknown = out;
|
||||
for (let i = 0; i < 20 && typeof cursor === 'object' && cursor !== null; i++) {
|
||||
cursor = (cursor as Record<string, unknown>).self;
|
||||
}
|
||||
expect(cursor).toBe('[MAX_DEPTH]');
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = { token: 'x', nested: { list: ['a'.repeat(3000)] } };
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
redactDetails(input);
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Recursive details redaction — runs at the single emit seam so every current
|
||||
* and future surface is guarded by default. Two rules:
|
||||
* 1. Any key matching the sensitive pattern is masked to '[REDACTED]'
|
||||
* (the value is never inspected or recursed into).
|
||||
* 2. Strings are truncated to ~2 KB post-redaction.
|
||||
*
|
||||
* Message bodies are excluded upstream by the per-surface mappers (shape only:
|
||||
* body_chars, attachment names) — this mask is defense-in-depth, not the
|
||||
* mechanism that keeps chat content out of the log.
|
||||
*/
|
||||
|
||||
const SENSITIVE_KEY = /(token|secret|key|password|credential|auth|bearer)/i;
|
||||
const MAX_VALUE_CHARS = 2048;
|
||||
const MAX_DEPTH = 8;
|
||||
|
||||
export function redactDetails(details: Record<string, unknown>): Record<string, unknown> {
|
||||
return redactObject(details, 0);
|
||||
}
|
||||
|
||||
function redactObject(obj: Record<string, unknown>, depth: number): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = SENSITIVE_KEY.test(k) ? '[REDACTED]' : redactValue(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactValue(value: unknown, depth: number): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.length > MAX_VALUE_CHARS ? `${value.slice(0, MAX_VALUE_CHARS)}…[truncated]` : value;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
// Depth cap doubles as a cheap cycle guard — details payloads are
|
||||
// JSON-serializable in practice, but the emit path must never throw.
|
||||
if (depth > MAX_DEPTH) return '[MAX_DEPTH]';
|
||||
if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1));
|
||||
return redactObject(value as Record<string, unknown>, depth);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Config + log are mocked so store/emit resolve a per-test temp DATA_DIR and
|
||||
// audit toggles. Getters keep the values live across vi.resetModules().
|
||||
const state = vi.hoisted(() => ({ dataDir: '', enabled: true, retention: 90 }));
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
get DATA_DIR() {
|
||||
return state.dataDir;
|
||||
},
|
||||
get AUDIT_ENABLED() {
|
||||
return state.enabled;
|
||||
},
|
||||
get AUDIT_RETENTION_DAYS() {
|
||||
return state.retention;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
let store: typeof import('./store.js');
|
||||
let emit: typeof import('./emit.js');
|
||||
let log: (typeof import('../log.js'))['log'];
|
||||
|
||||
beforeEach(async () => {
|
||||
state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-store-'));
|
||||
state.enabled = true;
|
||||
state.retention = 90;
|
||||
vi.resetModules();
|
||||
store = await import('./store.js');
|
||||
emit = await import('./emit.js');
|
||||
log = (await import('../log.js')).log;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.chmodSync(path.join(state.dataDir), 0o700);
|
||||
const auditDir = path.join(state.dataDir, 'audit');
|
||||
if (fs.existsSync(auditDir)) fs.chmodSync(auditDir, 0o700);
|
||||
fs.rmSync(state.dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function auditDir(): string {
|
||||
return path.join(state.dataDir, 'audit');
|
||||
}
|
||||
|
||||
function writeDayFile(day: string, lines = 1): void {
|
||||
fs.mkdirSync(auditDir(), { recursive: true });
|
||||
fs.writeFileSync(path.join(auditDir(), `${day}.ndjson`), '{"x":1}\n'.repeat(lines));
|
||||
}
|
||||
|
||||
function dayString(daysAgo: number): string {
|
||||
return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
const EVENT_INPUT = {
|
||||
actor: { type: 'human' as const, id: 'host:test' },
|
||||
origin: { transport: 'socket' as const },
|
||||
action: 'groups.list',
|
||||
resources: [{ type: 'agent_group' }],
|
||||
outcome: 'success' as const,
|
||||
};
|
||||
|
||||
describe('appendAuditLine', () => {
|
||||
it("appends one line to today's UTC day-file, creating the directory lazily", () => {
|
||||
expect(fs.existsSync(auditDir())).toBe(false);
|
||||
store.appendAuditLine('{"a":1}');
|
||||
store.appendAuditLine('{"b":2}');
|
||||
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
|
||||
expect(fs.readFileSync(file, 'utf8')).toBe('{"a":1}\n{"b":2}\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAuditEvent', () => {
|
||||
it('writes a schema_version-1 record with envelope fields and null enrichment', () => {
|
||||
emit.emitAuditEvent({ ...EVENT_INPUT, details: { limit: 100 } });
|
||||
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
|
||||
const record = JSON.parse(fs.readFileSync(file, 'utf8').trim());
|
||||
expect(record).toMatchObject({
|
||||
schema_version: 1,
|
||||
actor: { type: 'human', id: 'host:test', email: null, user_id: null, group_ids: null },
|
||||
origin: { transport: 'socket' },
|
||||
action: 'groups.list',
|
||||
outcome: 'success',
|
||||
correlation_id: null,
|
||||
details: { limit: 100 },
|
||||
});
|
||||
expect(record.event_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(new Date(record.time).toISOString()).toBe(record.time);
|
||||
});
|
||||
|
||||
it('redacts details at the emit seam', () => {
|
||||
emit.emitAuditEvent({ ...EVENT_INPUT, details: { env: { API_TOKEN: 'x' } } });
|
||||
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
|
||||
expect(fs.readFileSync(file, 'utf8')).toContain('"API_TOKEN":"[REDACTED]"');
|
||||
});
|
||||
|
||||
it('is a no-op when audit is disabled — the directory is never created', () => {
|
||||
state.enabled = false;
|
||||
emit.emitAuditEvent(EVENT_INPUT);
|
||||
expect(fs.existsSync(auditDir())).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open and loud when the append fails', () => {
|
||||
fs.mkdirSync(auditDir(), { recursive: true });
|
||||
fs.chmodSync(auditDir(), 0o500);
|
||||
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow();
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Audit append failed'),
|
||||
expect.objectContaining({ action: 'groups.list' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertAuditWritable', () => {
|
||||
it('creates the directory and probes it with a zero-byte append', () => {
|
||||
store.assertAuditWritable();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${store.utcDay()}.ndjson`))).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when the directory is not writable', () => {
|
||||
fs.mkdirSync(auditDir(), { recursive: true });
|
||||
fs.chmodSync(auditDir(), 0o500);
|
||||
expect(() => store.assertAuditWritable()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneAuditLog', () => {
|
||||
it('unlinks only day-files strictly older than the horizon', () => {
|
||||
writeDayFile(dayString(100));
|
||||
writeDayFile(dayString(91));
|
||||
writeDayFile(dayString(90));
|
||||
writeDayFile(dayString(1));
|
||||
fs.writeFileSync(path.join(auditDir(), 'not-a-day-file.txt'), 'keep');
|
||||
fs.writeFileSync(path.join(auditDir(), '2026-01-01.ndjson.bak'), 'keep');
|
||||
store.pruneAuditLog(90);
|
||||
const left = fs.readdirSync(auditDir()).sort();
|
||||
expect(left).toEqual(
|
||||
[`${dayString(90)}.ndjson`, `${dayString(1)}.ndjson`, '2026-01-01.ndjson.bak', 'not-a-day-file.txt'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps forever when retention is 0', () => {
|
||||
writeDayFile(dayString(400));
|
||||
store.pruneAuditLog(0);
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(400)}.ndjson`))).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op when the audit directory does not exist', () => {
|
||||
expect(() => store.pruneAuditLog(90)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneAuditLogIfDue', () => {
|
||||
it('prunes at most once per UTC day', () => {
|
||||
writeDayFile(dayString(100));
|
||||
store.pruneAuditLogIfDue();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(false);
|
||||
writeDayFile(dayString(100));
|
||||
store.pruneAuditLogIfDue();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when audit is disabled', () => {
|
||||
state.enabled = false;
|
||||
writeDayFile(dayString(100));
|
||||
store.pruneAuditLogIfDue();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing after markPrunedToday until the day rolls over', () => {
|
||||
store.markPrunedToday();
|
||||
writeDayFile(dayString(100));
|
||||
store.pruneAuditLogIfDue();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Audit day-file store — the tree's first structured file writer.
|
||||
*
|
||||
* Daily NDJSON files under data/audit/, named <UTC-day>.ndjson. Append-only is
|
||||
* structural: nothing in the system can update a line. Retention is unlinking
|
||||
* whole day-files past the horizon — a literal hard delete, no VACUUM. The
|
||||
* host process is the single writer by construction (both ncl transports and
|
||||
* every approval converge host-side).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS, DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
export const AUDIT_DIR = path.join(DATA_DIR, 'audit');
|
||||
|
||||
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** UTC day string (YYYY-MM-DD) — day-file names and prune boundaries. */
|
||||
export function utcDay(d: Date = new Date()): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function dayFilePath(day: string): string {
|
||||
return path.join(AUDIT_DIR, `${day}.ndjson`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single append point. Throws on fs failure — emitAuditEvent catches
|
||||
* (fail-open + loud lives there, not here).
|
||||
*/
|
||||
export function appendAuditLine(line: string): void {
|
||||
fs.mkdirSync(AUDIT_DIR, { recursive: true });
|
||||
fs.appendFileSync(dayFilePath(utcDay()), line + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time writability assert — called only when AUDIT_ENABLED. A zero-byte
|
||||
* append is a true write probe (fs.access can pass on read-only mounts).
|
||||
* Throws so main() refuses to start rather than run with a silent audit gap.
|
||||
*/
|
||||
export function assertAuditWritable(): void {
|
||||
fs.mkdirSync(AUDIT_DIR, { recursive: true });
|
||||
fs.appendFileSync(dayFilePath(utcDay()), '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink day-files strictly older than (today UTC − retentionDays). 0 or
|
||||
* negative = keep forever. Lexicographic compare is correct for ISO days.
|
||||
*/
|
||||
export function pruneAuditLog(retentionDays: number = AUDIT_RETENTION_DAYS): void {
|
||||
if (retentionDays <= 0) return;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(AUDIT_DIR);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means nothing to prune
|
||||
} catch {
|
||||
return; // Directory absent — nothing recorded yet.
|
||||
}
|
||||
const horizon = utcDay(new Date(Date.now() - retentionDays * DAY_MS));
|
||||
let pruned = 0;
|
||||
for (const entry of entries) {
|
||||
const m = DAY_FILE_RE.exec(entry);
|
||||
if (!m || m[1] >= horizon) continue;
|
||||
try {
|
||||
fs.unlinkSync(path.join(AUDIT_DIR, entry));
|
||||
pruned++;
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- one stubborn file must not stop the rest of the prune
|
||||
} catch (err) {
|
||||
log.error('Audit prune failed to unlink day-file', { file: entry, err });
|
||||
}
|
||||
}
|
||||
if (pruned > 0) {
|
||||
log.info('Audit retention pruned day-files', { pruned, retentionDays });
|
||||
}
|
||||
}
|
||||
|
||||
// Host-sweep throttle: the sweep ticks every 60s but retention only needs to
|
||||
// move once per UTC day.
|
||||
let lastPruneDay: string | null = null;
|
||||
|
||||
/** Sweep hook — no-op unless audit is enabled with a finite retention. */
|
||||
export function pruneAuditLogIfDue(): void {
|
||||
if (!AUDIT_ENABLED || AUDIT_RETENTION_DAYS <= 0) return;
|
||||
const today = utcDay();
|
||||
if (lastPruneDay === today) return;
|
||||
lastPruneDay = today;
|
||||
pruneAuditLog();
|
||||
}
|
||||
|
||||
/** Called after the boot-time prune so the first sweep tick doesn't re-prune. */
|
||||
export function markPrunedToday(): void {
|
||||
lastPruneDay = utcDay();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Canonical audit event vocabulary — schema_version 1.
|
||||
*
|
||||
* Fields are chosen to project losslessly onto OCSF and Elastic ECS; the
|
||||
* field mapping lives with the design docs and stays documentation until a
|
||||
* SIEM forwarder exists.
|
||||
*/
|
||||
|
||||
export type AuditActorType = 'human' | 'agent' | 'system';
|
||||
|
||||
export interface AuditActor {
|
||||
type: AuditActorType;
|
||||
/** `host:<os-user>` | `<channel>:<handle>` | agent group id | `host` (system). */
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuditOrigin {
|
||||
transport: 'socket' | 'container' | 'channel';
|
||||
session_id?: string;
|
||||
messaging_group_id?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export interface AuditResource {
|
||||
type: string;
|
||||
/** Omitted when only the attempted type is known (e.g. a denied list). */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export type AuditOutcome = 'success' | 'failure' | 'denied' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
/** What emit sites provide. Envelope fields are stamped by emitAuditEvent. */
|
||||
export interface AuditEventInput {
|
||||
actor: AuditActor;
|
||||
origin: AuditOrigin;
|
||||
/** Dotted namespaced verb, e.g. `groups.config.add-mcp-server`. */
|
||||
action: string;
|
||||
resources: AuditResource[];
|
||||
outcome: AuditOutcome;
|
||||
/** The approval id on gated chains; null/omitted otherwise. */
|
||||
correlationId?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored record — one NDJSON line. The directory-enrichment fields (email,
|
||||
* user_id, group_ids) ship in the schema but stamp null until the directory
|
||||
* adapter lands; they are filled at write time, never backfilled.
|
||||
*/
|
||||
export interface AuditEvent {
|
||||
event_id: string;
|
||||
time: string;
|
||||
schema_version: 1;
|
||||
actor: AuditActor & { email: null; user_id: null; group_ids: null };
|
||||
origin: AuditOrigin;
|
||||
action: string;
|
||||
resources: AuditResource[];
|
||||
outcome: AuditOutcome;
|
||||
correlation_id: string | null;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Actor for timer/sweep-driven outcomes (OneCLI expiry, startup sweeps, ghost rejects). */
|
||||
export const SYSTEM_ACTOR: AuditActor = { type: 'system', id: 'host' };
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 dashes→dots.
|
||||
*/
|
||||
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 {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -14,3 +14,4 @@ import './user-dms.js';
|
||||
import './dropped-messages.js';
|
||||
import './approvals.js';
|
||||
import './sessions.js';
|
||||
import './audit.js';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user