Skip to content

experiment#403

Draft
branchseer wants to merge 14 commits into
mainfrom
claude/fspy-supervisor-callbacks-Uvauu
Draft

experiment#403
branchseer wants to merge 14 commits into
mainfrom
claude/fspy-supervisor-callbacks-Uvauu

Conversation

@branchseer
Copy link
Copy Markdown
Member

Ongoing experiment — opened to run CI, not for merge.


Generated by Claude Code

claude added 11 commits May 15, 2026 10:30
Add as_os_str (Unix only) and to_cow_os_str (cross-platform) so callers
can recover an OsStr from a deserialized NativePath without going
through strip_path_prefix.
Adds the shared CallbackKind / CallbackRequest / CALLBACK_ACK wire
types used by the synchronous round-trip between a traced process and
the supervisor, plus the per-backend Payload endpoint fields
(CallbackConf on Unix, callback_pipe_name + callback_mask on Windows).
Both are absent / empty when no callback is registered.
Add FileEvent / FileEventKind / FileEventPath / BorrowedFile in a new
crate::callback module and Command::on_file_event(mask, callback) so a
consumer can register a callback that runs in the supervisor process.
Per-backend supervisor servers live in callback::{unix,windows}; the
Unix one wraps a Unix-domain socket and SCM_RIGHTS, the Windows one
wraps a named pipe with DuplicateHandle. SpawnError grows a
CallbackChannelCreation variant for binding failures.

No wiring into the OS-impl spawn() yet — that comes in follow-ups.
…tions

The preload connects per-event to the supervisor's Unix-domain socket,
passes the freshly opened (or about-to-be-closed) fd via SCM_RIGHTS,
sends a length-prefixed CallbackRequest, and blocks reading a single
ACK byte before letting the syscall return.

The open hooks now call the real libc fn first, capture the result fd,
keep the existing shared-memory event, and only then run the blocking
post-open callback. New close/fclose hooks fire the pre-close callback
while the fd is still valid; the fd's access mode is read with
F_GETFL, and obviously-non-file paths (sockets, anon inodes) are
filtered out before any round-trip. A thread-local reentrancy guard
prevents the round-trip's own socket I/O from recursing.

The callback channel is decoded from the EncodedPayload only when one
is registered, so the no-callback path remains a single Option check.
Add NotifyResponse { Continue, ReturnFd { fd, cloexec } } and a small
HandlerResponse trait so a handler can ask the supervisor to install a
file descriptor into the target (via SECCOMP_IOCTL_NOTIF_ADDFD with the
SEND flag) and complete the syscall atomically with the new fd as the
result. Keeping HandlerResponse separate from SeccompNotifyHandler
means the impl_handler! macro stays untouched — handlers that always
continue rely on the default impl.

supervise() now delegates to a new supervise_with(init, syscalls)
which builds each per-connection handler via the init closure and
filters exactly the syscalls passed in, letting callers inject
per-spawn state and decide at runtime which syscalls to intercept.

Also exposes Caller::pid() and Fd::raw() — both needed by handlers
that want to look up /proc/<pid>/fdinfo/<fd> for the open mode of a
descriptor about to be closed.
The supervisor's SyscallHandler now optionally carries a FileCallback
(injected via with_callback). When set, for each open*-syscall
notification the supervisor opens the file itself with the target's
flags, runs the callback on its own descriptor, and replies with a
ReturnFd response so the kernel installs that descriptor into the
target via ADDFD-SEND. For close notifications it reads the access
mode out of /proc/<pid>/fdinfo/<fd>, opens a fresh read-only fd for
the callback, then continues so the target performs the close.

handle_open now takes an is_open_syscall flag so it only fires the
post-open callback for real open* notifications — execve / stat /
access route through handle_open purely for access recording and must
not get an ADDFD response (execve does not return a descriptor).

/dev/, /proc/ and /sys/ paths are skipped before any supervisor open
to avoid spurious round-trips.
On non-musl hosts, when Command::on_file_event is set, spawn now binds
a UnixCallbackServer and threads its socket path + access-mode mask
into the Payload so the preload knows where to round-trip.

On Linux, supervise_with is invoked with the full syscall list
(including close) and a per-spawn closure that hands the FileCallback
to each SyscallHandler instance. With no callback registered, close is
filtered out of the syscall list so per-close overhead stays at zero.

The wait task drains the callback server (so every in-flight callback
finishes) before locking the IPC channel.
The preload connects per-event to the supervisor's named pipe and
sends [u32 len][CallbackRequest][u64 raw HANDLE], then blocks reading
a single ACK byte. The raw handle is duplicated out of this process
by the supervisor (DuplicateHandle); we just send its numeric value.

NtCreateFile / NtOpenFile are restructured to call the real fn first
(so file_handle is populated), keep the existing shared-memory event,
and only then run the post-open callback. A new NtClose detour fires
the pre-close callback while the handle is still valid.

NtClose runs for every handle type, so we keep a lock-free DashMap of
file handles whose open mode matched the mask. Only handles in that
map get a close callback, and the stored mode is reported back as the
event's access mode. A thread-local IN_CALLBACK guard suppresses the
recursion that would otherwise happen when the round-trip's own
CreateFileW / NtClose go back through the detours.
When Command::on_file_event is set, spawn now binds a
WindowsCallbackServer on a uniquely named pipe, threads its name plus
the access-mode mask into the Payload, and (after the child has been
spawned suspended but before ResumeThread) hands the child's process
handle to the server so DuplicateHandle on incoming callbacks can pull
the target's raw HANDLE into this process.

The wait task drains the callback server before locking the IPC
channel so every in-flight callback completes.
read_verify opens the file, reads it, and asserts the content is
non-empty — used by the seccomp blocking-callback test to prove the
ADDFD-installed descriptor in the target is usable.
read_verify_threads runs the same from four concurrent threads to
exercise the callback path under concurrency.
file_callback.rs covers the preload backend on Linux glibc / macOS /
Windows: blocking proof (target cannot progress while the callback
runs), the supervisor can read the passed descriptor, the close
callback fires before the close with a still-valid descriptor, the
mask filters events, and registering no callback leaves access
tracking unchanged.

static_executable.rs adds two seccomp-backend cases (ADDFD round-trip
+ multi-threaded concurrent opens).
@branchseer branchseer changed the title chore: ongoing experiment (CI only) experiment May 28, 2026
@branchseer branchseer force-pushed the claude/fspy-supervisor-callbacks-Uvauu branch from e29e264 to a81d817 Compare May 28, 2026 03:27
@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 28, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​dashmap@​6.2.110010093100100
Updatedcargo/​once_cell@​1.21.3 ⏵ 1.21.410010093100100

View full report

claude added 3 commits May 28, 2026 03:41
- Gate fspy::callback::unix to non-musl: it's only used by the preload
  backend, which itself is excluded on musl, so leaving it in caused
  dead_code errors under cargo's -D warnings on musl.
- Drop a redundant [`FileEvent`](crate::FileEvent) explicit link
  target — FileEvent is re-exported at the crate root.
- Disambiguate the [`impl_handler`] intra-doc link in
  HandlerResponse's doc to the macro at the crate root.
…ndle_close/open_callback

Skipping the thread-local reentrancy guard until the ctor has set the
global client keeps these paths infallible during very early (libdyld /
pre-ctor) opens and closes, where the thread-local accessor is the only
non-trivial operation either path would do anyway.

Also surface the actual stdout content in the cancellation test's
assertion message — without it, a child that crashes before writing
'ready' shows up as a bare assertion failure with no clue about why.
On macOS, tempfile::tempdir() returns paths under /var/folders/...,
which is a symlink to /private/var/folders/.... The open syscall sees
the literal /var/ path, but the close handler resolves the fd via
F_GETPATH which returns the canonical /private/var/ form. The tests
filter callback events by 'starts_with(dir_path)' — without canonical-
ization the close event's canonical path fails to match the non-
canonical prefix, so the Closing event is dropped and the close
callback test fails on macOS only.
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