Compare commits

..

13 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
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
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
14 changed files with 585 additions and 49 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: {
+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="182k tokens, 91% of context window">
<title>182k 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">182k</text>
<text x="71" y="14">182k</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

@@ -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);
}