Compare commits

...

16 Commits

Author SHA1 Message Date
gavrielc c6627d32e2 security: authorize create_agent host-side (approval for confined groups)
create_agent writes central-DB state (agent_groups, container_configs,
agent_destinations) and scaffolds host filesystem state, but the only
gate lived inside the untrusted container and is bypassed by writing the
outbound system row directly (the "host re-checks permission" comment was
false). Authorize host-side by CLI scope: trusted owner agent groups
(global scope) create sub-agents directly; confined groups require admin
approval via requestApproval. Adds regression tests for the branch.

Alternative to #2383 (which denies confined groups outright); co-authored
from that work.

Co-Authored-By: hinotoi-agent <paperlantern.agent@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:29:57 +03:00
gavrielc 6227bd1a5b Merge pull request #2478 from Hinotoi-agent/security/approval-response-admin-authz
[security] fix(approvals): require admin for approval responses
2026-06-09 22:29:07 +03:00
gavrielc 28032bc0ec Merge pull request #2468 from Hinotoi-agent/security/a2a-attachment-symlink-guard
[security] fix(agent-route): reject unsafe forwarded attachments
2026-06-09 22:29:03 +03:00
github-actions[bot] 3e3a2945a5 chore: bump version to 2.1.2 2026-06-09 18:04:39 +00:00
gavrielc f3fc18e56e chore: bump claude-code to 2.1.170 and agent SDK to 0.3.170
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:04:13 +03:00
github-actions[bot] d85efea229 chore: bump version to 2.1.1 2026-06-08 12:10:39 +00:00
github-actions[bot] c5b22cb308 docs: update token count to 183k tokens · 92% of context window 2026-06-08 12:10:36 +00:00
gavrielc 1592369201 Merge pull request #2713 from nanocoai/feat/egress-lockdown
feat(security): egress lockdown (opt-in, off by default)
2026-06-08 15:10:22 +03:00
Omri Maya 6420c0e254 feat(security): egress lockdown (opt-in) — agent egress only via OneCLI
Place agent containers on a Docker `--internal` network (no internet route)
with the OneCLI gateway attached, aliased host.docker.internal. The injected
proxy URL resolves only to the gateway, so a non-proxy-aware client or raw
socket has nowhere to go — closing the HTTPS_PROXY-bypass hole. The agent is
non-root with no NET_ADMIN, so it cannot undo this. Self-healing: the gateway
is re-attached at every spawn and on each host-sweep tick.

Fail-fast: when lockdown is enabled but the network/gateway can't be
established, refuse to spawn and surface a clear EgressLockdownError rather
than silently falling back to open egress. The host-sweep re-heal is the lone
exception — a heal failure there is logged, not fatal, since running agents
stay on the internal net (no leak) until the gateway returns.

Off by default — opt in with NANOCLAW_EGRESS_LOCKDOWN=true (so OSS users get
the prior behavior unchanged on pull). Also NANOCLAW_EGRESS_NETWORK and
ONECLI_GATEWAY_CONTAINER.

The lockdown logic lives in its own src/egress-lockdown.ts; container-runtime.ts
keeps only the generic runtime surface. Documented in docs/SECURITY.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:23:17 +03:00
gavrielc aef8d38b36 Merge pull request #2710 from markbala/docs/ollama-prefix-cache
docs(ollama): allow prompt caching by filtering the cache-busting hash
2026-06-07 23:21:45 +03:00
gavrielc 6d6f813deb Merge branch 'main' into docs/ollama-prefix-cache 2026-06-07 22:01:26 +03:00
markbala f9c86d0af2 docs(ollama): allow prompt caching by filtering the cache-busting hash
The Claude Agent SDK adds a per-request cch=<hash> to the front of every
prompt; it changes each turn, and Ollama's prompt cache only reuses a
prompt whose start is unchanged, so it re-reads the whole prompt every
time (slow). A tiny proxy filters the hash out (pins cch to a constant) so
caching kicks in. In our setup (31B on Apple Silicon) follow-up replies
went ~80s -> ~4s; numbers vary by model/hardware. Ollama ignores the hash,
so output is unchanged.

Scope: only the Claude-Code-CLI -> Ollama path; Codex/OpenCode emit no cch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 23:20:11 +08:00
github-actions[bot] 9edb33dd3a docs: update token count to 182k tokens · 91% of context window 2026-06-07 14:06:19 +00:00
gavrielc 8ba5261ae8 Merge pull request #2707 from nanocoai/feat/upgrade-tripwire
feat(upgrade): startup tripwire + upgrade marker
2026-06-07 17:06:03 +03:00
hinotoi-agent 728c6a641b fix(approvals): require admin for approval responses 2026-05-15 10:34:46 +08:00
hinotoi-agent 8385236c30 fix(agent-route): reject unsafe forwarded attachments 2026-05-14 21:04:04 +08:00
18 changed files with 742 additions and 51 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
# mean every rebuild silently picks up the latest and can break in lockstep
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.154
ARG CLAUDE_CODE_VERSION=2.1.170
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
+10 -10
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
@@ -19,23 +19,23 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.154", "", { "os": "darwin", "cpu": "x64" }, "sha512-5BgWEueP+cqoctWjZYhCbyltuaV/N2DmKDXD3/69cKaVmJp8XL9OCzlq/HEirA/+Ssjskx6hDUBaOcpuZ3iwQA=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRkW4SBL3W7zQvKscCIfIGlmoeuTbMV6dXFbPdmpRGvmYZIs79RpzO6xrGBnnhmm+B7znQ9oHAnffi/2FBgJbA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-o2bCQN4Xn3UqCLErC5m4T7u0yYArJYmgFCUFnA6K96DdW2RERvx+gTKXxWuHEBkDO+eMoHLHLxk0u2jGES00Ng=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-GpiFF8Ez6PbM3m0gqtCo/FKM346qyRdP7VhbmJzdnbNKTiiUZ66vDQyEUPZPCG24ZkrG4m96KpRIUwY08rHiNg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-zA7S8Lm6O4QBsUpbhiOht8BgiXHOBBFUIo8ZLK6r5wAatK3Q44syWVxICeyCnR6wqfnkf3cugCw27ycS6vVgaA=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.154", "", { "os": "win32", "cpu": "arm64" }, "sha512-cDW1YFbU/PJFlrGXhlAGcbkXt80sEO6WtnH8nN8YHXLn5NWduy2q7o/qC6i8XozgvRGf6t/eMoH7IasGIEDhDw=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.154", "", { "os": "win32", "cpu": "x64" }, "sha512-tSKaIIpL72OPg3WfzZTCIl8OJgcbq4qieu8/fDWjsdeQuari9gQMIuEflFphk9HqNsxpSmDqKi8Sm5mW2V566Q=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
@@ -5,8 +5,11 @@
* send_message(to="agent-name") since agents and channels share the
* unified destinations namespace.
*
* create_agent is admin-only. Non-admin containers never see this tool
* (see mcp-tools/index.ts). The host re-checks permission on receive.
* create_agent writes central-DB state. The host authorizes it by CLI scope:
* trusted owner agent groups (scope 'global') create directly; confined groups
* require admin approval (see src/modules/agent-to-agent/create-agent.ts). This
* tool just writes the outbound request; authorization is enforced host-side,
* not here — the container is untrusted and cannot be relied on to gate itself.
*/
import { writeMessageOut } from '../db/messages-out.js';
import { registerTools } from './server.js';
@@ -32,7 +35,7 @@ export const createAgent: McpToolDefinition = {
tool: {
name: 'create_agent',
description:
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. Admin-only. Fire-and-forget.',
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. May require admin approval before the agent is created. Fire-and-forget.',
inputSchema: {
type: 'object' as const,
properties: {
+42
View File
@@ -83,6 +83,48 @@ Each NanoClaw group gets its own OneCLI agent identity. This allows different cr
- Any credentials matching blocked patterns
- `.env` is shadowed with `/dev/null` in the project root mount
### 6. 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
credential injection, approvals, and audit. Egress lockdown closes that hole at
the network layer.
**How it works:** agents are placed on a Docker `--internal` network
(`nanoclaw-egress`) that has **no route to the internet**. The OneCLI gateway
container is attached to that network, aliased as `host.docker.internal`, so the
injected proxy URL (`…@host.docker.internal:10255`) resolves to the gateway
*container-to-container*. The gateway is therefore the **only reachable hop**
anything else has nowhere to go. The agent is non-root with no `NET_ADMIN`, so
it cannot undo this. Identical mechanism on macOS and Linux (no host firewall,
no `host-gateway` route).
- **Self-healing:** the gateway is re-attached to the network at every spawn and
on each host-sweep tick, so an out-of-band detach (e.g. `docker compose up` on
the OneCLI stack — its compose lives in `~/.onecli`, not this repo) recovers
automatically.
- **Fail-fast:** if lockdown is on but the network can't be created or the
gateway can't be attached (e.g. a non-standard gateway container name, or the
gateway isn't running), nanoclaw **refuses to spawn the agent** and surfaces a
clear error — it never silently falls back to open egress. Fix the cause (or
set `NANOCLAW_EGRESS_LOCKDOWN=false`) and retry. The host-sweep re-heal is the
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.
**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_NETWORK` | `nanoclaw-egress` | Network name. |
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
**⚠ 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
| Capability | Main Group | Non-Main Group |
+74
View File
@@ -53,6 +53,80 @@ Model selection considerations for Apple Silicon:
The agent uses tool calls extensively (read/write files, shell commands). Models that support tool use reliably work best. Gemma 4 and Qwen 3 Coder both handle structured tool calls well.
## Allowing Prompt Caching (filter the cache-busting hash)
Out of the box this path is slow — every reply re-reads the whole multi-thousand-token system prompt from scratch, even for a one-word answer. Ollama has a prompt cache that should skip that repeated work, but on this path it never kicks in.
**Cause.** The Claude Agent SDK adds a per-request hash to the front of every prompt — `x-anthropic-billing-header: ...; cch=<hash>;`. It changes on every request, and Ollama's cache only reuses a prompt whose start is unchanged. So that one shifting value at the front makes Ollama treat every prompt as new and re-read all of it. (Ollama ignores the hash itself, so filtering it has no effect on output.)
**Fix.** Run a tiny proxy between the container and Ollama that filters the hash out (pins `cch=<hash>` to a constant). The start of the prompt is now stable, so the cache kicks in and only the new message gets processed. In our setup — a 31B model on Apple Silicon — follow-up replies dropped from ~80s to ~4s; your numbers will vary with model size and hardware. Output is unchanged, since Ollama ignores the value anyway.
Point the agent group's `ANTHROPIC_BASE_URL` at the proxy instead of Ollama directly (everything else from the sections above is unchanged):
```
ANTHROPIC_BASE_URL=http://host.docker.internal:11999 # the proxy
# proxy forwards to http://127.0.0.1:11434 (Ollama)
```
The proxy is ~40 lines of dependency-free Node:
```js
// ollama-cch-proxy.mjs — normalize the SDK's per-request cch nonce so Ollama's
// prefix cache survives across turns. Listens on :11999, forwards to Ollama.
import http from 'node:http';
const TARGET_HOST = process.env.OLLAMA_HOST || '127.0.0.1';
const TARGET_PORT = Number(process.env.OLLAMA_PORT || 11434);
const LISTEN_PORT = Number(process.env.PROXY_PORT || 11999);
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
let body = Buffer.concat(chunks);
if (req.method === 'POST' && body.length) {
body = Buffer.from(body.toString('utf8').replace(/cch=[0-9a-f]+;/g, 'cch=00000;'), 'utf8');
}
const headers = { ...req.headers, host: `${TARGET_HOST}:${TARGET_PORT}`, 'content-length': String(body.length) };
const proxyReq = http.request(
{ host: TARGET_HOST, port: TARGET_PORT, method: req.method, path: req.url, headers },
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
proxyRes.pipe(res);
},
);
proxyReq.on('error', (e) => { res.writeHead(502); res.end(String(e)); });
proxyReq.end(body);
});
});
server.listen(LISTEN_PORT, '0.0.0.0', () => console.log(`cch-proxy :${LISTEN_PORT} -> ${TARGET_HOST}:${TARGET_PORT}`));
```
Run it durably so it survives reboots. On Linux, a systemd user service:
```ini
# ~/.config/systemd/user/ollama-cch-proxy.service
[Unit]
Description=Ollama cch-normalizing proxy for NanoClaw
After=network-online.target
[Service]
ExecStart=/usr/bin/node %h/.config/nanoclaw/ollama-cch-proxy.mjs
Restart=always
[Install]
WantedBy=default.target
```
```bash
systemctl --user enable --now ollama-cch-proxy
loginctl enable-linger "$USER" # so it runs without an active login session
```
On macOS use a `launchd` user agent (`~/Library/LaunchAgents/`) running the same script.
**Scope.** This only affects the Claude-Code-CLI → Ollama path described here. Codex and OpenCode don't use the Claude Agent SDK, so they never emit the `cch` hash and get prompt caching for free.
## What Changes at the Code Level
Three files need to support this feature. See `/add-ollama-provider` for the exact changes.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.0",
"version": "2.1.2",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="181k tokens, 91% of context window">
<title>181k tokens, 91% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="183k tokens, 92% of context window">
<title>183k tokens, 92% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">181k</text>
<text x="71" y="14">181k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">183k</text>
<text x="71" y="14">183k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+9 -2
View File
@@ -23,6 +23,7 @@ import { materializeContainerJson } from './container-config.js';
import { getContainerConfig } from './db/container-configs.js';
import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { EGRESS_NETWORK, egressNetworkArgs, ensureEgressNetwork } from './egress-lockdown.js';
import { composeGroupClaudeMd } from './claude-md-compose.js';
import { getAgentGroup } from './db/agent-groups.js';
import { getDb, hasTable } from './db/connection.js';
@@ -432,8 +433,14 @@ async function buildContainerArgs(
}
log.info('OneCLI gateway applied', { containerName });
// Host gateway
args.push(...hostGatewayArgs());
// Egress lockdown when enabled — throws if it can't be established, aborting
// the spawn rather than running with open egress. Otherwise the host gateway.
if (ensureEgressNetwork()) {
args.push(...egressNetworkArgs());
log.info('Egress lockdown active', { containerName, network: EGRESS_NETWORK });
} else {
args.push(...hostGatewayArgs());
}
// User mapping
const hostUid = process.getuid?.();
+95
View File
@@ -0,0 +1,95 @@
/**
* Egress lockdown — force ALL agent traffic through the OneCLI gateway.
* Agents run on a Docker `--internal` network (no internet route) with the
* gateway attached as host.docker.internal, so the injected proxy is the only
* reachable hop. Non-root, no NET_ADMIN — the agent can't undo it.
*
* Fail-fast: when the flag is on but the network/gateway can't be set up, throw
* rather than silently spawn an agent with open egress.
*/
import { execFileSync } from 'child_process';
import { CONTAINER_RUNTIME_BIN } from './container-runtime.js';
import { log } from './log.js';
/** Locked-down, no-internet network agents are placed on. */
export const EGRESS_NETWORK = process.env.NANOCLAW_EGRESS_NETWORK || 'nanoclaw-egress';
/** The OneCLI gateway container attached as the only egress hop. */
const ONECLI_GATEWAY_CONTAINER = process.env.ONECLI_GATEWAY_CONTAINER || 'onecli';
/** Off by default; set NANOCLAW_EGRESS_LOCKDOWN=true to opt in. */
const EGRESS_LOCKDOWN = process.env.NANOCLAW_EGRESS_LOCKDOWN === 'true';
/** Raised when lockdown is requested but can't be established. */
export class EgressLockdownError extends Error {
constructor(reason: string) {
super(
`Egress lockdown is on (NANOCLAW_EGRESS_LOCKDOWN=true) but ${reason}. ` +
`Refusing to spawn with open egress. Start the OneCLI gateway container ` +
`"${ONECLI_GATEWAY_CONTAINER}", or set NANOCLAW_EGRESS_LOCKDOWN=false to opt out.`,
);
this.name = 'EgressLockdownError';
}
}
function dockerOk(args: string[]): boolean {
try {
execFileSync(CONTAINER_RUNTIME_BIN, args, { stdio: 'pipe', timeout: 15000 });
return true;
} catch {
return false;
}
}
/** Is the OneCLI gateway currently attached to the egress network? */
function gatewayAttached(): boolean {
try {
const out = execFileSync(
CONTAINER_RUNTIME_BIN,
['network', 'inspect', EGRESS_NETWORK, '--format', '{{range .Containers}}{{.Name}} {{end}}'],
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 15000 },
);
return out.split(/\s+/).includes(ONECLI_GATEWAY_CONTAINER);
} catch {
return false;
}
}
/**
* Ensure the egress network exists with the OneCLI gateway attached (aliased
* host.docker.internal). Idempotent + self-healing. Returns false when lockdown
* is disabled (caller uses the host gateway), true when it's active. Throws
* EgressLockdownError when enabled but unestablishable — fail fast rather than
* spawn an agent with open egress.
*/
export function ensureEgressNetwork(): boolean {
if (!EGRESS_LOCKDOWN) return false;
if (
!dockerOk(['network', 'inspect', EGRESS_NETWORK]) &&
!dockerOk(['network', 'create', '--internal', EGRESS_NETWORK])
) {
throw new EgressLockdownError(`the "${EGRESS_NETWORK}" internal network could not be created`);
}
if (gatewayAttached()) return true;
if (
dockerOk(['network', 'connect', '--alias', 'host.docker.internal', EGRESS_NETWORK, ONECLI_GATEWAY_CONTAINER]) &&
gatewayAttached()
) {
log.info('Egress lockdown: OneCLI gateway attached', {
network: EGRESS_NETWORK,
gateway: ONECLI_GATEWAY_CONTAINER,
});
return true;
}
throw new EgressLockdownError(
`the OneCLI gateway "${ONECLI_GATEWAY_CONTAINER}" could not be attached to "${EGRESS_NETWORK}"`,
);
}
/** CLI args placing a container on the locked-down egress network. */
export function egressNetworkArgs(): string[] {
return ['--network', EGRESS_NETWORK];
}
+11
View File
@@ -29,6 +29,7 @@
import type Database from 'better-sqlite3';
import fs from 'fs';
import { ensureEgressNetwork } from './egress-lockdown.js';
import { getActiveSessions } from './db/sessions.js';
import { getAgentGroup } from './db/agent-groups.js';
import {
@@ -132,6 +133,16 @@ export function stopHostSweep(): void {
async function sweep(): Promise<void> {
if (!running) return;
// Re-heal the egress network so already-running agents keep their gateway hop
// if it was detached out-of-band. Best-effort here: a heal failure isn't a
// leak (agents stay on the internal net), so log and continue. No-op when
// lockdown is disabled.
try {
ensureEgressNetwork();
} catch (err) {
log.error('Egress lockdown re-heal failed', { err });
}
try {
const sessions = getActiveSessions();
for (const session of sessions) {
@@ -443,4 +443,28 @@ describe('routeAgentMessage return-path', () => {
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes');
});
it('file forwarding: skips symlinked source files', async () => {
const secretPath = path.join(TEST_DIR, 'host-secret.txt');
fs.writeFileSync(secretPath, 'host-secret-bytes');
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-symlink');
fs.mkdirSync(outboxDir, { recursive: true });
fs.symlinkSync(secretPath, path.join(outboxDir, 'safe-name.txt'));
await routeAgentMessage(
{
id: 'msg-with-symlink',
platform_id: B,
content: JSON.stringify({ text: 'see attached', files: ['safe-name.txt'] }),
in_reply_to: null,
},
S1,
);
const bRows = readInbound(B, SB.id);
expect(bRows).toHaveLength(1);
const parsed = JSON.parse(bRows[0].content);
expect(parsed.attachments).toHaveLength(0);
});
});
+50 -2
View File
@@ -40,6 +40,11 @@ export interface ForwardedAttachment {
localPath: string;
}
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
/**
* Copy file attachments from the source agent's outbox into the target
* agent's inbox. Returns attachments using the formatter's existing
@@ -57,6 +62,11 @@ export function forwardAttachedFiles(
): ForwardedAttachment[] {
if (source.filenames.length === 0) return [];
if (!isSafeAttachmentName(source.messageId)) {
log.warn('agent-route: rejecting unsafe source outbox message id', { sourceMsgId: source.messageId });
return [];
}
const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId);
if (!fs.existsSync(sourceDir)) {
log.warn('agent-route: source outbox dir missing, no files forwarded', {
@@ -66,6 +76,26 @@ export function forwardAttachedFiles(
return [];
}
let realSourceDir: string;
try {
const sourceDirStat = fs.lstatSync(sourceDir);
if (!sourceDirStat.isDirectory() || sourceDirStat.isSymbolicLink()) {
log.warn('agent-route: rejecting unsafe source outbox dir', {
sourceMsgId: source.messageId,
sourceDir,
});
return [];
}
realSourceDir = fs.realpathSync(sourceDir);
} catch (err) {
log.warn('agent-route: failed to inspect source outbox dir', {
sourceMsgId: source.messageId,
sourceDir,
err,
});
return [];
}
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
fs.mkdirSync(targetInboxDir, { recursive: true });
@@ -79,15 +109,33 @@ export function forwardAttachedFiles(
continue;
}
const src = path.join(sourceDir, filename);
if (!fs.existsSync(src)) {
let realSrc: string;
try {
const srcStat = fs.lstatSync(src);
if (!srcStat.isFile() || srcStat.isSymbolicLink()) {
log.warn('agent-route: rejecting unsafe source outbox file', {
sourceMsgId: source.messageId,
filename,
});
continue;
}
realSrc = fs.realpathSync(src);
} catch {
log.warn('agent-route: referenced file missing in source outbox, skipped', {
sourceMsgId: source.messageId,
filename,
});
continue;
}
if (!isPathInside(realSourceDir, realSrc)) {
log.warn('agent-route: rejecting source file outside source outbox dir', {
sourceMsgId: source.messageId,
filename,
});
continue;
}
const dst = path.join(targetInboxDir, filename);
fs.copyFileSync(src, dst);
fs.copyFileSync(realSrc, dst);
attachments.push({
name: filename,
filename,
@@ -0,0 +1,115 @@
/**
* Tests for create_agent host-side authorization.
*
* Regression guard for the audit finding: `create_agent` is a privileged
* central-DB write with no host-side authz. The fix authorizes by CLI scope —
* trusted owner agent groups ('global') create directly; confined groups
* ('group', the default and the prompt-injection victim) must get admin
* approval. These tests pin that branch decision.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Session } from '../../types.js';
// Mocks for the collaborators the branch decides between / depends on.
const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
const mockGetContainerConfig = vi.fn();
const mockCreateAgentGroup = vi.fn();
const mockInitGroupFilesystem = vi.fn();
const mockWriteDestinations = vi.fn();
const mockNotifyWrite = vi.fn();
vi.mock('../approvals/index.js', () => ({
requestApproval: (...a: unknown[]) => mockRequestApproval(...a),
}));
vi.mock('../../db/container-configs.js', () => ({
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
}));
vi.mock('../../db/agent-groups.js', () => ({
getAgentGroup: (id: string) => ({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: '' }),
getAgentGroupByFolder: () => undefined,
createAgentGroup: (...a: unknown[]) => mockCreateAgentGroup(...a),
}));
vi.mock('../../group-init.js', () => ({
initGroupFilesystem: (...a: unknown[]) => mockInitGroupFilesystem(...a),
}));
vi.mock('./write-destinations.js', () => ({
writeDestinations: (...a: unknown[]) => mockWriteDestinations(...a),
}));
vi.mock('./db/agent-destinations.js', () => ({
getDestinationByName: () => undefined,
createDestination: vi.fn(),
normalizeName: (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
}));
// notifyAgent writes to the session inbound.db + wakes the container; stub both.
vi.mock('../../session-manager.js', () => ({
writeSessionMessage: (...a: unknown[]) => mockNotifyWrite(...a),
}));
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../db/sessions.js', () => ({
getSession: (id: string) => ({ id, agent_group_id: 'ag-1' }),
}));
import { handleCreateAgent } from './create-agent.js';
const SESSION = { id: 'sess-1', agent_group_id: 'ag-1' } as Session;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleCreateAgent — scope-based authorization', () => {
it('global scope: creates directly, no approval requested', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
expect(mockRequestApproval).not.toHaveBeenCalled();
expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1);
expect(mockInitGroupFilesystem).toHaveBeenCalledTimes(1);
});
it('group scope (default): requires approval, does NOT create directly', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
expect(mockRequestApproval.mock.calls[0][0]).toMatchObject({ action: 'create_agent' });
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
expect(mockInitGroupFilesystem).not.toHaveBeenCalled();
});
it('missing config: fails closed to approval (no direct create)', async () => {
mockGetContainerConfig.mockReturnValue(undefined);
await handleCreateAgent({ name: 'Scout' }, SESSION);
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
});
it('disabled/other scope: requires approval', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
await handleCreateAgent({ name: 'Scout' }, SESSION);
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
});
it('empty name: neither creates nor requests approval', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
await handleCreateAgent({ name: '' }, SESSION);
expect(mockRequestApproval).not.toHaveBeenCalled();
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
});
});
+91 -16
View File
@@ -1,20 +1,29 @@
/**
* `create_agent` delivery-action handler.
*
* Spawns a new agent group on demand from the parent agent, wires bidirectional
* agent_destinations rows, projects the new destination into the parent's
* running container, and notifies the parent.
* SECURITY: `create_agent` writes to the CENTRAL DB (agent_groups,
* container_configs, agent_destinations) and scaffolds host filesystem state
* a privileged operation a confined container is otherwise architecturally
* barred from. The container's MCP tool gate is inside the (untrusted)
* container and is trivially bypassed by writing the outbound system row
* directly, so authorization MUST be enforced host-side. Trusted owner agent
* groups (CLI scope 'global') create directly; every other (confined) group
* requires admin approval via `requestApproval` matching `ncl groups create`
* (access: 'approval') and the self-mod actions. `applyCreateAgent` runs the
* creation on approve; `performCreateAgent` is the shared body.
*/
import path from 'path';
import { GROUPS_DIR } from '../../config.js';
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
import { getContainerConfig } from '../../db/container-configs.js';
import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js';
import { initGroupFilesystem } from '../../group-init.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { AgentGroup, Session } from '../../types.js';
import { requestApproval, type ApprovalHandler } from '../approvals/index.js';
import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js';
import { writeDestinations } from './write-destinations.js';
@@ -34,23 +43,95 @@ function notifyAgent(session: Session, text: string): void {
}
}
/**
* Delivery-action entry.
*
* Authorization depends on the calling group's CLI scope:
* - `global` (set by init-first-agent for trusted owner agent groups):
* create immediately. create_agent is the intended primitive for these
* privileged agents, and an approval tap on every sub-agent spawn would be
* needless friction.
* - anything else (the default `group` scope the realistic
* prompt-injection victim): require an admin to approve before any
* central-DB write. `applyCreateAgent` runs on approve.
* Unknown/missing config fails closed to the approval path.
*/
export async function handleCreateAgent(content: Record<string, unknown>, session: Session): Promise<void> {
const requestId = content.requestId as string;
const name = content.name as string;
const instructions = content.instructions as string | null;
const name = typeof content.name === 'string' ? content.name : '';
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
if (!name) {
notifyAgent(session, 'create_agent failed: name is required.');
return;
}
const sourceGroup = getAgentGroup(session.agent_group_id);
if (!sourceGroup) {
notifyAgent(session, `create_agent failed: source agent group not found.`);
notifyAgent(session, 'create_agent failed: source agent group not found.');
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
return;
}
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));
return;
}
await requestApproval({
session,
agentName: sourceGroup.name,
action: 'create_agent',
payload: { name, instructions },
title: `Create agent: ${name}`,
question: `Agent "${sourceGroup.name}" wants to create a new sub-agent "${name}" (a new agent group with its own workspace and container). Approve?`,
});
}
/**
* Approval handler: performs the creation once an admin approves a request from
* a confined (non-global) agent group. `session` is the requesting parent.
*/
export const applyCreateAgent: ApprovalHandler = async ({ session, payload, notify }) => {
const name = typeof payload.name === 'string' ? payload.name : '';
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
if (!name) {
notify('create_agent approved but the request had no name.');
return;
}
const sourceGroup = getAgentGroup(session.agent_group_id);
if (!sourceGroup) {
notify('create_agent approved but the source agent group no longer exists.');
log.warn('create_agent apply failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
return;
}
await performCreateAgent(name, instructions, session, sourceGroup, notify);
};
/**
* Core creation: writes the new agent group + bidirectional destinations and
* scaffolds its filesystem, then reports via `notify`. Authorization is the
* CALLER's responsibility (the global-scope shortcut in handleCreateAgent or
* admin approval via applyCreateAgent) never call this from an unauthorized
* path, as it performs privileged central-DB writes a confined container is
* otherwise barred from.
*/
async function performCreateAgent(
name: string,
instructions: string | null,
session: Session,
sourceGroup: AgentGroup,
notify: (text: string) => void,
): Promise<void> {
const localName = normalizeName(name);
// Collision in the creator's destination namespace
if (getDestinationByName(sourceGroup.id, localName)) {
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
notify(`Cannot create agent "${name}": you already have a destination named "${localName}".`);
return;
}
@@ -66,7 +147,7 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
const resolvedPath = path.resolve(groupPath);
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
notify(`Cannot create agent "${name}": invalid folder path.`);
log.error('create_agent path traversal attempt', { folder, resolvedPath });
return;
}
@@ -115,12 +196,6 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
// tries to send to the newly-created child.
writeDestinations(session.agent_group_id, session.id);
// Fire-and-forget notification back to the creator
notifyAgent(
session,
`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`,
);
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 });
// Note: requestId is unused — this is fire-and-forget, not request/response.
void requestId;
}
+10 -4
View File
@@ -1,9 +1,13 @@
/**
* Agent-to-agent module inter-agent messaging and on-demand agent creation.
*
* Registers one delivery action (`create_agent`). The sibling `channel_type === 'agent'`
* routing path is NOT a system action core `delivery.ts` dispatches into
* `./agent-route.js` via a dynamic import when it sees `msg.channel_type === 'agent'`.
* Registers one delivery action (`create_agent`) plus its matching approval
* handler `create_agent` writes central-DB state, so confined (non-global)
* groups require admin approval (the delivery action queues the request;
* `applyCreateAgent` runs on approve); trusted global-scope groups create
* directly. The sibling `channel_type === 'agent'` routing path is NOT a system
* action core `delivery.ts` dispatches into `./agent-route.js` via a dynamic
* import when it sees `msg.channel_type === 'agent'`.
*
* Host integration points:
* - `src/container-runner.ts::spawnContainer` dynamically imports
@@ -17,6 +21,8 @@
* throw because the module isn't installed.
*/
import { registerDeliveryAction } from '../../delivery.js';
import { handleCreateAgent } from './create-agent.js';
import { registerApprovalHandler } from '../approvals/index.js';
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
registerDeliveryAction('create_agent', handleCreateAgent);
registerApprovalHandler('create_agent', applyCreateAgent);
@@ -0,0 +1,164 @@
/**
* Regression coverage for approval response authorization.
*
* Approval cards may be delivered to an admin DM, but the callback payload is
* still untrusted input. The response handler must not dispatch sensitive
* approval handlers merely because a response carries a valid questionId.
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createSession, createPendingApproval, getPendingApproval } from '../../db/sessions.js';
import { upsertUser } from '../permissions/db/users.js';
import { grantRole } from '../permissions/db/user-roles.js';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approval-response-authz' };
});
const TEST_DIR = '/tmp/nanoclaw-test-approval-response-authz';
function now() {
return new Date().toISOString();
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
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(),
});
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
describe('approval response authorization', () => {
it('ignores a valid approval id clicked by a non-admin user', async () => {
const { registerApprovalHandler } = await import('./primitive.js');
const { handleApprovalsResponse } = await import('./response-handler.js');
const handler = vi.fn().mockResolvedValue(undefined);
registerApprovalHandler('install_packages', handler);
createPendingApproval({
approval_id: 'appr-1',
session_id: 'sess-1',
request_id: 'appr-1',
action: 'install_packages',
payload: JSON.stringify({ packages: ['left-pad'] }),
created_at: now(),
title: 'Install packages',
options_json: JSON.stringify([]),
});
const claimed = await handleApprovalsResponse({
questionId: 'appr-1',
value: 'approve',
userId: 'stranger',
channelType: 'telegram',
platformId: 'dm-stranger',
threadId: null,
});
expect(claimed).toBe(true);
expect(handler).not.toHaveBeenCalled();
expect(getPendingApproval('appr-1')).toBeDefined();
});
it('allows an owner/admin click to dispatch the registered approval handler', async () => {
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() });
const { registerApprovalHandler } = await import('./primitive.js');
const { handleApprovalsResponse } = await import('./response-handler.js');
const handler = vi.fn().mockResolvedValue(undefined);
registerApprovalHandler('install_packages_allowed', handler);
createPendingApproval({
approval_id: 'appr-2',
session_id: 'sess-1',
request_id: 'appr-2',
action: 'install_packages_allowed',
payload: JSON.stringify({ packages: ['left-pad'] }),
created_at: now(),
title: 'Install packages',
options_json: JSON.stringify([]),
});
const claimed = await handleApprovalsResponse({
questionId: 'appr-2',
value: 'approve',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
expect(claimed).toBe(true);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ userId: 'telegram:owner' }));
expect(getPendingApproval('appr-2')).toBeUndefined();
});
it('allows global admins to resolve approvals without a session-scoped agent group', async () => {
upsertUser({ id: 'telegram:global-admin', kind: 'telegram', display_name: 'Global Admin', created_at: now() });
grantRole({
user_id: 'telegram:global-admin',
role: 'admin',
agent_group_id: null,
granted_by: null,
granted_at: now(),
});
const { registerApprovalHandler } = await import('./primitive.js');
const { handleApprovalsResponse } = await import('./response-handler.js');
const handler = vi.fn().mockResolvedValue(undefined);
registerApprovalHandler('global_admin_allowed', handler);
createPendingApproval({
approval_id: 'appr-3',
session_id: 'sess-1',
agent_group_id: null,
request_id: 'appr-3',
action: 'global_admin_allowed',
payload: JSON.stringify({ packages: ['left-pad'] }),
created_at: now(),
title: 'Install packages',
options_json: JSON.stringify([]),
});
const claimed = await handleApprovalsResponse({
questionId: 'appr-3',
value: 'approve',
userId: 'global-admin',
channelType: 'telegram',
platformId: 'dm-global-admin',
threadId: null,
});
expect(claimed).toBe(true);
expect(handler).toHaveBeenCalledTimes(1);
expect(getPendingApproval('appr-3')).toBeUndefined();
});
});
+34 -7
View File
@@ -18,27 +18,35 @@ import type { ResponsePayload } from '../../response-registry.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { PendingApproval } from '../../types.js';
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
import { getApprovalHandler } from './primitive.js';
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
// OneCLI credential approvals — resolved via in-memory Promise first.
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
return true;
}
// DB-backed pending_approvals.
const approval = getPendingApproval(payload.questionId);
if (!approval) return false;
if (!isAuthorizedApprovalClick(approval, payload)) {
log.warn('Ignoring unauthorized approval response', {
approvalId: approval.approval_id,
action: approval.action,
userId: payload.userId,
channelType: payload.channelType,
});
return true;
}
if (approval.action === ONECLI_ACTION) {
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
return true;
}
// Row exists but the in-memory resolver is gone (timer fired or the process
// was in a weird state). Nothing to do — just drop the row.
deletePendingApproval(payload.questionId);
return true;
}
await handleRegisteredApproval(approval, payload.value, payload.userId ?? '');
await handleRegisteredApproval(approval, payload.value, namespacedUserId(payload) ?? '');
return true;
}
@@ -104,3 +112,22 @@ async function handleRegisteredApproval(
deletePendingApproval(approval.approval_id);
await wakeContainer(session);
}
function namespacedUserId(payload: ResponsePayload): string | null {
if (!payload.userId) return null;
return payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
}
function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
const userId = namespacedUserId(payload);
if (!userId) return false;
const agentGroupId =
approval.agent_group_id ?? (approval.session_id ? getSession(approval.session_id)?.agent_group_id : null);
if (!agentGroupId) {
return isOwner(userId) || isGlobalAdmin(userId);
}
return hasAdminPrivilege(userId, agentGroupId);
}