Skip to content

feat(mcp): add CBM_IN_PROCESS to serve stdio MCP without the daemon - #2072

Open
pmaxvsbobo wants to merge 4 commits into
DeusData:mainfrom
pmaxvsbobo:feat/in-process-mcp-for-sandboxed-hosts
Open

pmaxvsbobo wants to merge 4 commits into
DeusData:mainfrom
pmaxvsbobo:feat/in-process-mcp-for-sandboxed-hosts

Conversation

@pmaxvsbobo

Copy link
Copy Markdown

What does this PR do?

Adds an opt-in CBM_IN_PROCESS environment variable that serves an MCP session
in-process over stdio, without starting or connecting to the coordination daemon.

Why it's needed: on hosts whose sandbox denies socket syscalls, the daemon
handshake can never complete. A macOS seatbelt profile that permits filesystem
access but applies (deny network*) makes both bind() and connect() fail
with EPERMAF_UNIX included — so there is no reachable rendezvous and no
alternative transport. Today that surfaces as a 30s hang and a misleading
"CBM daemon is active or starting" error;

The change is small because the server already existed: cbm_mcp_server_run()
is a complete socket-free stdio loop (zero socket/IPC references in
src/mcp/mcp.c) whose only caller was tests/test_mcp.c. This routes MCP
clients to it behind a gate, so default behaviour is unchanged.

Measured under an active sandbox: 31.1s failure → 4.6s initialize result.

Trade-off (documented in README.md and docs/CONFIGURATION.md): no daemon
means no cross-session coordination — no shared watchers, no shared indexing
jobs, no UI, no exact-build admission barrier.

Checklist

  • Every commit is signed off (git commit -s) — required, CI rejects
    unsigned commits (DCO, see CONTRIBUTING.md)
  • Tests pass locally (make -f Makefile.cbm test)
  • Lint passes (make -f Makefile.cbm lint-ci)
  • New behavior is covered by a test (reproduce-first for bug fixes)

@pmaxvsbobo
pmaxvsbobo requested a review from DeusData as a code owner September 6, 2026 07:20
@pmaxvsbobo
pmaxvsbobo force-pushed the feat/in-process-mcp-for-sandboxed-hosts branch 2 times, most recently from 0664442 to bae6977 Compare September 6, 2026 07:42
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@DeusData

DeusData commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for measuring it (31 s to 4.6 s is a real pain point, and the analysis of why the failure is misreported is right: the connect result carries no errno, so an EPERM connect is classified as a reserved endpoint and retried blindly). Before we decide on the direction I want to understand the use case better, because a daemon opt-out is trickier than the diff suggests: the daemon is where the cross-session mutation lease, the index executor, the project-mutation guard and the exact-build admission live, and the in-process server as wired here gets none of them while background tasks stay on, so an in-process index_repository writes the shared cache without the lease that protects a concurrent daemon session on the same machine. Could you tell us more about:

  1. The environment. Which host, sandbox or agent product applies (deny network*) to the MCP server process, and is that its default profile or one you wrote? A concrete reproduction (profile snippet or product name plus version) would help us judge how common this is.
  2. Why AF_UNIX cannot be allowed. Most seatbelt profiles that deny network still permit unix-domain sockets to a specific path ((allow network* (local unix-socket)) style rules, or a path-scoped allow). Is that ruled out where you are, and if so by whom?
  3. What you need inside that sandbox. Is it read-only querying of an index built elsewhere, or does the session also have to index and re-index? If reads are enough, a read-only in-process mode sidesteps the lease problem entirely; if writes are needed, how would you propose to coordinate with a daemon session on the same cache?
  4. Whether the real defect alone would do for now. If the connect failure surfaced immediately as "the sandbox denies unix sockets: …" instead of a 30 s "daemon is active or starting", would that unblock you while the design question is settled? That fix (errno propagation in the connect result) is small, clearly right, and we would take it as its own PR straight away.

No decision yet on the mode itself; the maintainer wants to think about it with your answers in hand. Thank you again for the careful write-up.

@pmaxvsbobo

Copy link
Copy Markdown
Author

Thanks — you're right about the lease, and it was worse than "no lease is acquired". Fixed inc72cb8b; the mode is now read-only.
mcp_project_mutation_begin() is return !srv->mutation_begin || srv->mutation_begin(...), so an unset guard fails open and the mutation proceeds. I'd wired only set_tool_profile and set_session_context, left background_tasks at its default true, and installed no mutation guard, no try guard and no index executor. Every other construction site in the tree already gets this right — daemon/application.c, ui/http_server.c, and main.c's own local-CLI path in the same function I edited. I should have mirrored them.
I verified it rather than taking it on description: with the three calls removed, index_repository on an in-process session doesn't merely skip the guard, it returns {"status":"indexed"} with isError:false and writes the cache. So this was live, not theoretical. Thank you for catching it.

What c72cb8b does — mirroring ui/http_server.c:

  • background_tasks(false), so maybe_auto_index() can't index on the initialize path
  • a mutation guard that refuses, with the try guard deliberately left NULL: with mutation_begin set and mutation_try_begin NULL, mcp_project_mutation_try_begin() also returns false, so opportunistic writes during a read are refused too
  • an index executor that rejects with a message naming the reason, rather than letting the guard report "blocked by an active index" when nothing is blocking
    The test now asserts both that the call is refused and that no .db appears under CBM_CACHE_DIR — an error message alone could be emitted after a partial write, and only the second check distinguishes a refusing guard from an absent one. I confirmed that assertion fails against a build with the three calls removed.

@pmaxvsbobo

Copy link
Copy Markdown
Author

Answers to your questions:
1. The environment. An enterprise-managed build of Claude Code, deployed by my employer's
security org — not a profile I wrote, and not one I can change. It applies a seatbelt profile
to every subprocess it spawns, MCP servers included: (allow default) for the filesystem,
(deny network*), then four narrow re-allows. The profile file is regenerated from a compiled-in
constant at every launch and sha256-verified, so local edits are reverted; changing the policy
requires review by the team that owns it. I'd rather not paste my employer's profile wholesale
in a public issue, but I can share the exact deny/allow lines if that helps. The shape is:
filesystem unrestricted, all networking denied, a handful of vendor-owned control sockets
re-allowed by exact filename.

I can't tell you how common this is beyond my own org. But the pattern — sandbox the agent's
subprocesses, deny networking, leave the filesystem alone — isn't exotic, and CBM is unusually
exposed to it because coordination is mandatory and the only transport is AF_UNIX. #1533/#2023
(Windows app-container ACLs) and #1830 (Docker userns-remap) look like the same class arriving
through different mechanisms.

2. Why AF_UNIX can't be allowed. It's ruled out, by the team that owns the profile. Two specifics that matter:

  • network* covers network-bind as well as network-outbound, so bind() and connect() are both EPERM for AF_UNIX. There's no connect-only loophole.
  • The only unix-socket re-allows are (allow network-outbound (remote unix-socket (regex ...))) matching an exact filename pattern for the vendor's own control sockets. They're not directory-scoped, so no value of CBM_RUNTIME_DIR can produce a matching path — CBM hardcodes both the cbm-daemon-<uid> subdirectory and the cbm-<hash>.sock filename.
    Measured inside one sandboxed process: connect() to a live socket under $HOMEEPERM;
    connect() to the CBM socket under /private/tmpEPERM; bind() in $HOME, /private/tmp
    and $TMPDIREPERM, 8/8. The loopback UI port is EPERM too.

There is an escape hatch — the vendor lets a user exempt a named binary from the sandbox entirely — but I don't think that's the better answer. It trades a scoped capability for removing containment altogether from a process that indexes source code, and it's a per-machine manual step the project can't rely on.

3. What I need inside the sandbox — reads only. This is the useful answer, and it's why your suggestion is simply better than what I sent. Querying an index built elsewhere is enough. Indexing already happens outside the sandbox, from an ordinary terminal where the daemon works normally and owns the watcher and the lease. The sandboxed session only needs search_graph, search_code, get_code_snippet, trace_path and friends against the cache the daemon built. So the read-only mode in c72cb8b is not a concession — it's what I actually needed, and it removes the coordination question rather than answering it. If you'd prefer this expressed through the existing --tool-profile machinery rather than a new env var, say so and I'll rework it; a read-only profile is arguably where this belongs.

4. Would the errno fix alone unblock me? Not on its own — an accurate instant error still leaves the tools unusable, so I'd still be reaching for a terminal. But it's worth far more than its size, and I'd rather it landed first regardless. It cost me most of a day: the 30 s wait lands just past the 30 s MCP handshake budget my host imposes, so all I saw was "connection timed out after 30000ms" — the actual error never arrived. And when it does arrive it actively misleads: the daemon was healthy throughout, and a running daemon is what makes the wait last the full 30 s, since holding the cohort lock is what makes presence read COORDINATED and the EPERM classify as RESERVED/waitable. daemon start made it worse, which is the opposite of what the message implies.

I'll send that as its own PR: an os_error field on cbm_daemon_runtime_connect_result_t, and a fast-fail for non-retryable values (EPERM, EACCES) in cbm_daemon_bootstrap_classify_failed_connect() with a message naming the cause. Independent of whatever you decide here, and it should help #1955 too — that report hits the same message, and it currently can't distinguish "denied" from "contended" from "still starting".

On CI: DCO is fixed and both commits are signed off. The clang-format failures in cypher.c, runtime.c, version_cohort.c, frontend.c and cli.c are pre-existing and not from this branch — this PR touches four files, none of them those. I only hit them locally because Homebrew ships clang-format 23 while you pin 20. The one violation that was mine (an over-wrapped fprintf in src/main.c) is fixed.

On hosts whose sandbox denies socket syscalls, an MCP client can never
complete the daemon handshake. A macOS seatbelt profile that permits
filesystem access but applies `(deny network*)` makes both bind() and
connect() fail with EPERM, AF_UNIX included, so there is no reachable
rendezvous and no alternative transport.

The failure is slow and misreported rather than immediate. Daemon
presence is inferred from a POSIX file lock, which such a profile still
permits, so a live daemon reads as COORDINATED. connect() then returns
EPERM, but cbm_daemon_runtime_connect_result_t (daemon/runtime.h) carries
no errno, so cbm_daemon_bootstrap_classify_failed_connect() cannot
distinguish "denied" from "still starting" and returns RESERVED, which is
waitable. The bootstrap loop retries every BOOTSTRAP_RETRY_NS until
MAIN_MCP_STARTUP_TIMEOUT_MS expires -- roughly 30,000 blind retries of an
instantaneous EPERM -- and only then reports failure. Hosts that impose
their own 30s MCP handshake budget see a timeout just before the error
arrives, which hides the cause.

A complete socket-free stdio server already existed but was unreachable
from main(): cbm_mcp_server_run() (mcp.h) has no socket, sockaddr_un or
IPC reference anywhere in src/mcp/mcp.c, and its only caller was
tests/test_mcp.c. This routes MCP clients to it behind an opt-in
CBM_IN_PROCESS gate, so default behaviour is byte-for-byte unchanged.

The store is resolved with cbm_mcp_server_new(NULL) exactly as
daemon/application.c and ui/http_server.c already do, so an in-process
session reads the same CBM_CACHE_DIR indexes a daemon builds. Background
tasks stay at the standalone default documented in mcp.h: with no config
store attached, maybe_auto_index() resolves auto_index=false and returns
without doing synchronous work on the initialize path.

Trade-off, documented in both README.md and docs/CONFIGURATION.md: no
cross-session coordination -- no shared watchers, no shared indexing
jobs, no UI, no exact-build admission barrier.

Measured on macOS with the sandbox active (sandbox_check() == 1):
  without CBM_IN_PROCESS: no response, rc=1 after 31.1s
  with    CBM_IN_PROCESS: initialize result in 4.6s

scripts/test_mcp_in_process.py asserts the handshake completes AND that
no rendezvous appears under CBM_RUNTIME_DIR, since "it answered" alone
would still pass on an ordinary host if the session silently fell back to
the daemon. Verified to fail against an unpatched binary.

Signed-off-by: pmaxvsbobo <pmaxvsbobo@gmail.com>
… taking them unguarded

Review caught a real hazard, and it was worse than "no lease is acquired":
mcp_project_mutation_begin() is

    return !srv->mutation_begin || srv->mutation_begin(...)

so an UNSET guard fails OPEN and the mutation proceeds. The first version of
this branch set no mutation guard, no try guard and no index executor, and left
background_tasks at its default true — so an in-process index_repository wrote
the shared cache under CBM_CACHE_DIR with no cross-session lease at all, racing
any daemon session mutating the same project.

Every other construction site in the tree already gets this right:
daemon/application.c installs the guard, the try guard, a config store and an
index executor; ui/http_server.c installs a refusing index executor plus both
guards; and main.c's own local-CLI path, in the same function this patch edits,
installs both guards. This mirrors them.

Without a daemon there is nothing to acquire a lease FROM, so the honest answer
is that an in-process session cannot coordinate a write and must refuse it.
Indexing stays with the daemon, outside the sandbox — which is the actual use
case anyway: the sandboxed session only needs to read an index built elsewhere.

  - background_tasks off, so maybe_auto_index() cannot index on initialize
  - a mutation guard that refuses, and the try guard deliberately left NULL:
    with mutation_begin set and mutation_try_begin NULL,
    mcp_project_mutation_try_begin() also returns false, so opportunistic
    writes during a read are refused too
  - an index executor that rejects with a message naming the reason, rather
    than letting the guard report it as "blocked by an active index" when
    nothing is blocking

Test extended to cover it, and the assertion was verified to fail against a
build with the three calls removed. That build did not merely skip the guard —
it returned {"status":"indexed"}, isError:false, having written the cache.
So the test now asserts both that the call is refused AND that no .db appears
under CBM_CACHE_DIR: an error message alone could be emitted after a partial
write, and only the second check distinguishes a refusing guard from an absent
one.

Signed-off-by: pmaxvsbobo <pmaxvsbobo@gmail.com>
@pmaxvsbobo
pmaxvsbobo force-pushed the feat/in-process-mcp-for-sandboxed-hosts branch from bc945a4 to 838fb70 Compare September 7, 2026 06:44
@DeusData DeusData added enhancement New feature or request security Security vulnerabilities, hardening editor/integration Editor compatibility and CLI integration priority/normal Standard review queue; useful PR with ordinary maintainer urgency. labels Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

editor/integration Editor compatibility and CLI integration enhancement New feature or request priority/normal Standard review queue; useful PR with ordinary maintainer urgency. security Security vulnerabilities, hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants