Skip to content

[security] fix(container): prevent host file read/delete via container-controlled outbox paths#2001

Merged
gavrielc merged 2 commits into
nanocoai:mainfrom
Hinotoi-agent:fix/outbox-path-confinement
Apr 30, 2026
Merged

[security] fix(container): prevent host file read/delete via container-controlled outbox paths#2001
gavrielc merged 2 commits into
nanocoai:mainfrom
Hinotoi-agent:fix/outbox-path-confinement

Conversation

@Hinotoi-agent
Copy link
Copy Markdown
Contributor

Summary

This PR hardens NanoClaw's host/container filesystem boundary for outbound attachments and outbox cleanup.

Before this change, the agent container controlled both the outbound message rows and the writable session outbox mounted at /workspace. The host later trusted container-supplied messages_out.id and content.files values as path components when reading attachments and recursively cleaning outbox/<messageId>/. A compromised or prompt-injected container could use traversal or symlinks to make the host read or delete paths outside the intended message outbox.

This patch confines outbound attachment handling to a single message outbox directory by validating path segments, rejecting symlinks, and realpath-checking host reads and cleanup targets before performing file I/O.

Security issues covered

Issue Impact Fixed by
Container-controlled attachment filename traversal Host can read files outside outbox/<messageId>/ and deliver them as attachments Basename-only filename validation plus realpath containment
Container-controlled attachment symlink Safe-looking attachment filename can point outside the outbox and be read by the host lstat rejection of symlinked/non-file attachment paths
Container-controlled message-id cleanup traversal Host can recursively delete outside the intended outbox path after delivery Basename-only message-id validation plus cleanup containment checks

Before this PR

  • readOutboxFiles() joined each container-supplied filename directly under outbox/<messageId>/.
  • readOutboxFiles() followed symlinks because it used existsSync() and readFileSync() on the joined path.
  • clearOutbox() used the container-controlled outbound message id directly in a recursive rmSync() path.
  • Normal channel delivery lacked the basename validation already present in the agent-to-agent forwarding path.

After this PR

  • Outbound messageId values must be simple path segments before file read or cleanup operations proceed.
  • Outbound attachment filenames must be simple basenames, not paths.
  • Attachment paths are inspected with lstat() and symlinks/non-files are skipped.
  • Real attachment targets must resolve under the canonical message outbox directory.
  • Cleanup refuses unsafe message ids, symlinked cleanup directories, and any resolved cleanup target outside the session outbox.
  • Normal basename attachment delivery and normal cleanup behavior continue to work.

Why this matters

The session directory is intentionally writable by the agent container:

mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false });

That writable mount is expected for the container-owned outbound.db and outbox files. The security boundary problem is that host code later performed file reads and recursive deletion using path strings from that container-owned state.

If those strings are not constrained at the host-side sink, the container can turn the host process into a confused deputy. The host has the NanoClaw user's filesystem privileges, so reading or deleting outside the intended outbox can expose or corrupt host-side state that was not supposed to be mounted into the container.

How this differs from #1967 and #1999

This is related to, but distinct from, two existing hardening areas:

Both fixes are needed because they protect different host operations over container-writable trees.

Attack flow

1. Agent container controls /workspace/outbound.db and /workspace/outbox/.
2. Container writes a messages_out row with content.files containing a traversal path,
   or creates outbox/<message-id>/safe-name.txt as a symlink outside the outbox.
3. Host delivery reads content.files and calls readOutboxFiles().
4. Vulnerable host code follows the path and reads outside outbox/<message-id>/.
5. Separately, a traversal message id can cause clearOutbox() to recursively
   remove an escaped path after delivery.

Affected code

File Area
src/session-manager.ts readOutboxFiles() and clearOutbox() host-side outbox file operations
src/delivery.ts Calls readOutboxFiles() using content.files from container-owned outbound messages
src/container-runner.ts Mounts the session directory writable into the agent container
src/host-core.test.ts Regression tests for traversal, symlink, and normal behavior

Root cause

  • The container legitimately controls outbound message data and outbox files.
  • Host code reused container-controlled strings as filesystem path components.
  • existsSync() / readFileSync() followed symlinks at the read sink.
  • Recursive cleanup used a container-controlled message id without validating that the final target remained inside the intended outbox namespace.

CVSS assessment

Issue: host/container filesystem boundary bypass via outbound outbox paths

CVSS v3.1: 8.8 High

Vector: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Rationale: exploitation requires control of an agent container or its container-owned outbound state, but once that precondition is met the vulnerable host process can read or delete files outside the intended outbox boundary with the NanoClaw host user's privileges. Scope changes from container-controlled state to host-side filesystem effects.

Safe reproduction steps

A safe local proof can be constructed with a temporary NanoClaw data directory:

  1. Create a session outbox such as data/v2-sessions/ag-poc/sess-poc/outbox/msg-poc/.
  2. Create a marker file outside that outbox, for example data/outside.txt.
  3. On vulnerable code, call readOutboxFiles('ag-poc', 'sess-poc', 'msg-poc', ['../../../../../outside.txt']) or create safe-name.txt as a symlink to the marker and request ['safe-name.txt'].
  4. For cleanup, create data/victim-dir/keep.txt and call clearOutbox('ag-poc', 'sess-poc', '../../../../victim-dir').

Expected vulnerable behavior

On vulnerable code:

  • traversal or symlinked attachment paths can be returned by readOutboxFiles() as outbound file data;
  • a traversal message id can make clearOutbox() remove a directory outside the intended message outbox.

Changes in this PR

  • Adds a host-side path-segment validator for outbox message ids and filenames.
  • Rejects unsafe outbound message ids before attachment reads and cleanup.
  • Rejects attachment filenames containing path separators, traversal sentinels, NUL, or non-basename values.
  • Uses lstat() to reject symlinked/non-file attachment paths.
  • Requires attachment realpaths to remain under the canonical message outbox directory.
  • Rejects symlinked/non-directory cleanup targets.
  • Requires cleanup realpaths to remain under the canonical session outbox directory.
  • Adds regression coverage for traversal read, symlink read, traversal cleanup, and normal basename behavior.

Files changed

Category Files What changed
Host outbox hardening src/session-manager.ts Validates path segments and confines read/delete sinks with lstat() and realpath() checks
Regression tests src/host-core.test.ts Adds tests for escaped attachment reads, symlinked attachments, escaped cleanup, and normal file handling

Maintainer impact

This should be low-impact for legitimate attachments because normal outbox attachment names are simple filenames. The change intentionally rejects path-like attachment names and symlinked attachments from the container-owned outbox.

If a future feature needs nested outbox paths, it should add an explicit safe abstraction rather than passing arbitrary container-controlled path strings to host file APIs.

Fix rationale

The host-side file operation is the correct place to enforce this boundary. Container-side validation is useful defense-in-depth, but the host must treat outbound DB rows and outbox files as untrusted because they are written from inside the container.

Basename-only validation matches the existing attachment model and the validation already used in the agent-to-agent forwarding path. lstat() plus realpath() containment ensures that safe-looking names cannot escape through symlinks.

Type of change

  • Security fix
  • Bug fix
  • Tests / regression coverage
  • Documentation only
  • Refactor only

Test plan

Ran:

corepack pnpm test -- --run src/host-core.test.ts
corepack pnpm run typecheck
corepack pnpm run format:check
corepack pnpm exec eslint src/session-manager.ts src/host-core.test.ts
git diff --check

Notes:

  • The targeted test command currently runs the broader Vitest suite in this repo; it passed: 23 files / 201 tests.
  • Targeted ESLint completed with warnings only from the existing catch-all warning rule.
  • Full corepack pnpm run lint still reports unrelated pre-existing errors in src/channels/cli.ts and src/container-runner.ts; this PR does not touch those files.
  • The local Husky pre-commit hook invokes bare pnpm, which is not on this automation PATH. The commit was created with HUSKY=0 after the Corepack validation above passed.

Disclosure notes

This PR is intentionally bounded to host-side file read/delete sinks reached from container-owned outbox state. It does not claim a Docker runtime breakout such as privileged-container escape, Docker socket access, host PID namespace access, or added Linux capabilities. The issue is a NanoClaw host/container confused-deputy boundary bypass through path handling in the host process.

@Hinotoi-agent Hinotoi-agent changed the title [security] fix(container): confine outbound attachment paths [security] fix(container): prevent host file read/delete via container-controlled outbox paths Apr 25, 2026
dim0627 added a commit to dim0627/nanoclaw that referenced this pull request Apr 27, 2026
…ments

Stacked on nanocoai#2001. That PR confines outbound attachment reads and
post-delivery cleanup; this commit extends the same guard to the
mirror-image inbound path.

`extractAttachmentFiles` writes channel-supplied attachment bytes
under `inbox/<messageId>/<filename>` via host-side `mkdirSync` +
`writeFileSync`. Both `messageId` (carried through from the channel
adapter — typically a platform-supplied id) and `att.name` (a free-form
field a malicious user can craft in a Slack/Discord/etc. message) are
untrusted. Without validation, a traversal sequence in either segment
overwrites arbitrary host-writable files, and a compromised container
with /workspace write access can pre-place a symlink at the inbox dir
or filePath to redirect the write.

Mirror the four defenses nanocoai#2001 added on the outbound side:

- `isSafePathSegment(messageId)` before any inbox path is built.
- `isSafePathSegment(filename)` before `att.name` is used as a path
  segment; preserve the host-generated `attachment-${Date.now()}`
  fallback for attachments with no name.
- `lstatSync(inboxDir)` to refuse a pre-placed symlink before
  `mkdirSync` would silently follow it; `realpathSync` + `isPathInside`
  to assert the resolved dir still sits inside the session's inbox root.
- `writeFileSync(..., { flag: 'wx' })` to refuse following a
  pre-existing symlink at the target file path or overwriting any
  existing file. Surface `EEXIST` as a logged skip.

Tests (added under the existing `describe('session manager', ...)`):
- traversal in `att.name` does not write to the resolved escape path;
- a symlinked inbox subdir is rejected and the symlink target is left
  untouched;
- a pre-existing symlink at the file path is not followed;
- traversal in `messageId` materialises no inbox subtree;
- safe basenames still land at `inbox/<msgId>/<name>`.

`pnpm test` (23 files, 206 tests) and `pnpm exec tsc --noEmit` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gavrielc gavrielc force-pushed the fix/outbox-path-confinement branch from bcbed31 to 3502d9b Compare April 30, 2026 22:26
Hinotoi-agent and others added 2 commits May 1, 2026 01:27
…ments

Mirrors the four defenses on the outbound side onto extractAttachmentFiles:

  1. Reject unsafe messageId via isSafeAttachmentName before any inbox path
     is built. WhatsApp passes msg.key.id through raw and that field is
     client generated, so a peer can craft it; future end to end encrypted
     adapters will have the same property.
  2. lstatSync on the inbox dir refuses a pre placed symlink before
     mkdirSync would silently follow it.
  3. realpathSync + isPathInside contains the resolved dir under the
     session inbox root.
  4. writeFileSync uses the wx flag so a pre placed symlink at the file
     path is refused atomically by the kernel; EEXIST surfaces as a
     logged skip.

Threat: the session dir is mounted writable into the container at
/workspace, so a compromised agent can pre place inbox/<future msgId>/
as a symlink and wait for a chat message with a matching id to redirect
the host write. The four guards together close that window.

Consolidates with the existing isSafeAttachmentName helper from
attachment-safety.ts rather than introducing a duplicate basename
validator inside session-manager.

Co-Authored-By: Daisuke Tsuji <dim0627@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gavrielc gavrielc force-pushed the fix/outbox-path-confinement branch from 3502d9b to fc3c11b Compare April 30, 2026 22:27
Copy link
Copy Markdown
Collaborator

@gavrielc gavrielc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased on current main, deduplicated path-segment helper against existing isSafeAttachmentName. Added a follow-up commit applying the same defenses to the inbound side, co-authored with Daisuke Tsuji from #2053. Tests green (234).

@gavrielc gavrielc merged commit 7814e45 into nanocoai:main Apr 30, 2026
mzazon added a commit to mzazon/nanoclaw that referenced this pull request May 6, 2026
Brings in 202 upstream commits since our last sync. Key additions:
- Circuit breaker for crash loop protection
- Outbox path-confinement security fix (nanocoai#2001)
- Inbound DB fresh-open fix for virtiofs/NFS (nanocoai#2160)
- Orphan processing_ack cleanup (nanocoai#2151)
- Pre-task scripts on follow-up poll injections (nanocoai#2114)
- Attachment naming/safety refactor
- Setup flow improvements (splash, headless, env reuse)
- Channel approval flow enhancements

Conflict resolution:
- Dockerfile: kept OPENCODE_VERSION, adopted upstream's pinned Vercel 52.2.1
- poll-loop.ts: took upstream's async pre-task handling (subsumes our try/catch guard)
- agent-route.ts: took upstream's factored-out isSafeAttachmentName
- src/index.ts: kept both readEnvFile and new circuit-breaker imports
- setup/verify.ts, agent-ping.ts: took upstream's simplified verify flow
- package.json: took upstream version 2.0.25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants