Skip to content

Use O_NOFOLLOW on file writes: flusher, credential, settings#132

Merged
JAORMX merged 1 commit intomainfrom
sec/write-nofollow
Apr 17, 2026
Merged

Use O_NOFOLLOW on file writes: flusher, credential, settings#132
JAORMX merged 1 commit intomainfrom
sec/write-nofollow

Conversation

@JAORMX
Copy link
Copy Markdown
Contributor

@JAORMX JAORMX commented Apr 17, 2026

Summary

Closes a MEDIUM security finding about destination symlink-follow on three write sites. os.OpenFile/os.WriteFile without O_NOFOLLOW lets a pre-existing symlink at the destination silently redirect the write to an attacker-chosen target.

New internal/infra/safeio package with OpenForWrite and WriteFileNoFollow. Three call sites migrated:

  • Flusher (internal/infra/review/flusher.go) — snapshot → host workspace.
  • Credential store (internal/infra/credential/store.go) — host → VM rootfs.
  • Settings injector (internal/infra/settings/injector.go) — both directions, three sites.

Clarifying the flusher threat model

The common case of "user has a symlink in their workspace that the agent edits" is unaffected. The snapshot cloner preserves symlinks, writes inside the VM resolve through them to regular target files, and the differ attributes the change to the target path (not the symlink path). The O_NOFOLLOW in the flusher is defense-in-depth for narrow scenarios — a symlink smuggled past the differ's non-regular-file filter, or a TOCTOU race planting a symlink between diff and flush.

Verified by end-to-end smoke: a workspace with alias.txt -> real-target.txt has the agent's edit to alias.txt correctly flushed to real-target.txt (the resolved target), no rejection.

Error message (when O_NOFOLLOW does trigger)

/path/to/file: refusing to follow symlink to "target" — edit that file directly or add the path to .broodboxignore

Names the target (so users know what bbox would have overwritten) and both recovery paths.

Test plan

  • task fmt && task lint && task test — all green
  • safeio unit tests: creates new, truncates existing, rejects symlink (with target name in error), rejects dangling symlink, raw OpenForWrite exercises the happy path
  • TestFSFlusher_RefusesToWriteThroughSymlink: in-workspace symlink redirect is caught by O_NOFOLLOW after ValidateInBounds passes, and the target file is verified untouched
  • Full e2e: legitimate workspace with a symlink; agent modifies the file; flush correctly updates the resolved target; no rejection

Follow-ups (not in this PR)

  • Additional write sites in internal/infra/vm/* (knownhosts, gitconfig, mcpconfig, hooks) have the same exposure class and should migrate to safeio.
  • Flusher's src-side open still follows symlinks; the narrow TOCTOU window between HashFile and os.Open on snapPath is unchanged by this patch.
  • Flusher's partial-flush semantics (returns on first error) could be replaced with all-or-nothing pre-validation for cleaner failure handling.

🤖 Generated with Claude Code

`os.OpenFile` with `O_CREATE|O_WRONLY|O_TRUNC` and `os.WriteFile`
silently follow a pre-existing symlink at the destination path. This
let three write sites be redirected to attacker-chosen targets:

- Flusher (internal/infra/review/flusher.go): a symlink at the flush
  destination — smuggled past the differ's non-regular-file filter
  or planted in a TOCTOU race between diff and flush — would cause
  bbox to write agent-authored content through to the symlink's
  target. (The common case of "user has a symlink in their workspace
  that the agent edits" is unaffected: the snapshot cloner preserves
  symlinks, writes inside the VM resolve through them to regular
  target files, and the differ attributes the change to the target
  path.)
- Credential store (internal/infra/credential/store.go): a malicious
  or buggy guest base image with pre-existing symlinks at the
  credential destination paths could redirect credential writes out
  of the rootfs.
- Settings injector (internal/infra/settings/injector.go): same
  concern as the credential store for per-agent settings (e.g.
  ~/.claude/*, ~/.config/opencode/*).

Add a shared `internal/infra/safeio` package with `OpenForWrite` and
`WriteFileNoFollow`. Both use `O_NOFOLLOW` on the final path
component; if the destination is a symlink, open fails with ELOOP
and is wrapped as:

    /path/to/file: refusing to follow symlink to "target" —
    edit that file directly or add the path to .broodboxignore

The message names the symlink target so users hitting the rejection
can see what bbox would have overwritten, and points at the two
recovery paths (edit the target, or exclude via .broodboxignore).

Wire all three write sites through the new helper. ValidateInBounds
remains the first line of defense (catches paths escaping the base
directory via symlink resolution); safeio.OpenForWrite is
defense-in-depth for the in-bounds-but-symlinked case.

Follow-ups (not in this PR):
- Additional write sites in internal/infra/vm/* (knownhosts.go,
  gitconfig.go, mcpconfig.go, hooks.go) have the same exposure class
  and should migrate to safeio. They operate on the bbox-built guest
  rootfs where a malicious base image could plant symlinks.
- Flusher's src-side open (copyFilePreserveMode) still follows
  symlinks; the narrow TOCTOU window between HashFile and os.Open
  on snapPath is unchanged by this patch.
- Flusher's partial-flush semantics (returns on first error) could
  be replaced with all-or-nothing pre-validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JAORMX JAORMX merged commit 6ba28f8 into main Apr 17, 2026
8 checks passed
@JAORMX JAORMX deleted the sec/write-nofollow branch April 17, 2026 09:09
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.

1 participant