Sitelet https://github.com/brianonbased-dev/HoloScript/pull/457
Skip to content

fix(mcp-server): a mesh tool served over HTTP reaches only the public internet unless an operator calls it - #457

Open
brianonbased-dev wants to merge 4 commits into
mainfrom
claude1/mesh-invoke-public-only
Open

brianonbased-dev wants to merge 4 commits into
mainfrom
claude1/mesh-invoke-public-only

Conversation

@brianonbased-dev

@brianonbased-dev brianonbased-dev commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Board: task_1790639545383_cixd (P1). It was filed from the pre-review of #410 and #420. Stacked on #420 (the outbound guard): the base is claude1/video-fetch-public-only, so merge #420 first.

What was wrong

holomesh_invoke_tool sent a POST to whatever URL a manifest named, using the manifest's own headers. It followed redirects (axios's default) and returned the answer whole.

  • holomesh_publish_tool needs only tools:write, and it checked no URL.
  • Invoke also runs manifests from the shared knowledge store. Their "attestation" is a hash of their own content, not a vouch for the address.

So any tools:write key could make the hosted server call an internal address and read the reply. Open registration issues those keys. Internal addresses include *.railway.internal, the container's own ports, and link-local metadata. With a redirect, a GET of any of them was also possible.

What changes

Verified (round 1)

mesh-tool-registry.test.ts gains 5 tests. Stand-in servers on 127.0.0.1 count what reaches them:

test result
tools:write invokes a tool at an internal address refused, 0 arrivals
operator invokes it 1 arrival, answer read
a 307 from an allowed first hop not followed; target 0 arrivals
a 5 MB + 1 answer refused
publish an internal address refused for tools:write, allowed for an operator

Watched failing, one break at a time:

break red
every invoke takes the unguarded path 3
every caller trusted 4
redirects followed 1
no size cap 1
publish unchecked 1

Other suites.

Round 2: the pre-review (head dde980c)

The same seat's pre-review (not the distinct-seat review) found one P1 and two P3s. All three are fixed.

P1: a call that lost its caller counted as an operator.

  • Cause. handleTool fills a missing signing context with {signer:'stdio-local', scopes:['admin:*']} whenever HOLOSCRIPT_API_KEY is set. The hosted server sets it too.
  • Chain. An execute_workflow step whose batch_tool_call child lost the caller's context reached callerMayReachPrivateNetwork looking like an operator. Publish then skipped the address check. Invoke took the unguarded path to an internal address and returned the answer.
  • Fix. The rule now reads the stdio-local signer as no context: trusted on stdio, not over HTTP. fix(mcp-server): a tool that runs other tools runs them as the real caller, re-checked, on every path #407 removes that context-less re-entry at its source, and this makes the rule safe on its own.
  • Still open. Task x5ku owns making the bridge itself stdio-only.

P3: an operator could run a stranger's manifest unguarded. Manifests from the shared knowledge store carry their author's address and headers.

  • The operator exemption now covers only a manifest published on this server: same id and same content hash.
  • A knowledge-store entry can no longer take over the id of a tool published here. The local tool keeps its id.
  • The operator path follows no redirect, because a 307 keeps a POST's body.

P3: the health sweep followed redirects and read any size. It now follows none (a redirect still counts as an answer) and reads at most 64 KB.

New tests: 6 more in the same file, 21 in all. They use 127.0.0.1 stand-ins, and one stand-in answers for the knowledge store, so nothing leaves the machine:

test result
handleTool('holomesh_invoke_tool'): no context, key set, over HTTP refused, 0 arrivals. The same call on stdio: 1 arrival
operator, manifest not published here refused, 0 arrivals
operator, knowledge-store manifest, through the handler refused, 0 arrivals
a store entry that claims a local id the local tool answers; the claimant gets 0 arrivals
operator, a tool published here answers 307 not followed; target 0 arrivals
sweep: one 307 answer and one answer of 64 KB + 1 the redirect is not followed; the oversized answer counts as unhealthy

Watched failing, one break at a time. Each break goes red only in its own test:

break red
stdio-local admin trusted again 1
a manifest not published here exempted 2
a store entry takes a local id 1
an operator invoke follows redirects 1
the sweep follows redirects 1
the sweep reads any size 1

Results.

  • The real code passes 21/21.
  • The neighbouring suites pass 149/149: outbound guard, signing-ctx invoke, premium exits, mesh store, workspace vantage, daemon lifecycle and AI generation.
  • tsc is clean (pre-commit gate), and so is Prettier.

Not in this change

The sweep's address check. sweepMeshToolRegistry still sends its periodic GET of each tool's /health without an address check, because tools an operator publishes may live at private addresses.

  • Since round 2 it follows no redirect and reads at most 64 KB.
  • Only a healthy or unhealthy bit comes out of it.
  • A non-operator can publish only public addresses. What remains is a name that later resolves inward: it gets one GET of /health per sweep.

The made-up admin identity. It still exists on the hosted server for other gates, for example the fork sandbox gate's granted scopes. #407 removes the known ways in. Task x5ku makes the bridge stdio-only.

Review

A distinct seat is needed. The author is claude1 / claudecode-claude-x402. claude2 owns mcp-server and the Railway lane; cursor-claude-x402 was asked for #420.

🤖 Generated with Claude Code

claudecode-claude-x402 and others added 2 commits September 28, 2026 19:25
… internet unless an operator calls it

Task cixd (P1, filed from the pre-review of #410/#420). holomesh_invoke_tool POSTed to
whatever URL a manifest named, with the manifest's own headers, followed redirects (axios
default), and returned the answer whole. holomesh_publish_tool needs only tools:write and
checked no URL, and invoke also runs manifests from the shared knowledge store, whose
"attestation" is a hash of their own content. So any tools:write key (open registration issues
them) could make the hosted server call an internal address and read the reply.

- invokePublishedMeshTool: an mcp-http call from a caller who is not an operator (and not the
  local stdio process) goes through #420's outbound guard: the address is checked, the
  connection is pinned to it, no redirect is followed, the answer is capped at 5 MB, and a
  non-2xx answer fails as axios's did. Operators keep the unguarded path.
- holomesh_publish_tool refuses a non-public mcp-http address from such a caller up front. The
  invoke check stays the one that matters: a name can change what it resolves to, and remote
  manifests never pass through publish.
- The rule for who may reach a private network moves beside the guard
  (callerMayReachPrivateNetwork, exported), and handlers.ts's isTrustedCaller now calls it, so the
  video fetch and the mesh invoke cannot drift apart.

mesh-tool-registry.test.ts gains 5 tests with stand-in servers on 127.0.0.1 that count arrivals:
a tools:write invoke of an internal address is refused with 0 arrivals; an operator reaches it
and reads the answer; a 307 from an allowed first hop is not followed (target 0 arrivals); a
5 MB + 1 answer is refused; publishing an internal address is refused for tools:write and
allowed for an operator. Watched failing, one break at a time: unguarded invoke -> 3 red; every
caller trusted -> 4 red; redirects followed -> 1 red; no cap -> 1 red; publish unchecked -> 1 red.
Restored 15/15; outbound-url-guard 43/43 and mesh-invoke-tool-signing-ctx pass. twin-earth has
the same 2 failures with this change set aside (main's, fixed by #407). tsc 0 errors.

Stacked on #420 (the guard); merge #420 first.

Not in this change: sweepMeshToolRegistry's periodic GET of each published tool's /health is
still unguarded. It returns only healthy/unhealthy, and after this change a non-operator can only
publish public addresses, so what is left is a name that later resolves inward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and a stranger's mesh manifest is always guarded

Answers the #457 pre-review (same seat as the author, so it is a pre-review;
the distinct-seat review is still owed).

P1: handleTool fills a missing signing context with
{signer:'stdio-local', scopes:['admin:*']} whenever HOLOSCRIPT_API_KEY is set,
on the hosted server too. An execute_workflow step whose batch child lost its
caller's context therefore reached callerMayReachPrivateNetwork looking like
an operator: mesh publish skipped the address check, and invoke took the
unguarded path to an internal address and returned the answer. The rule now
reads the stdio-local signer as no context: trusted on the stdio server, not
over HTTP. (#407 removes that context-less re-entry at its source; this makes
the rule safe on its own.)

P3: an operator's invoke could pick a manifest from the shared knowledge
store, written by anyone, and POST to its address with its headers, unguarded.
The operator exemption now covers only a manifest published on this server
(same id and content hash), and a knowledge-store entry can no longer take
over the id of a tool published here. The operator path follows no redirects
either (a 307 keeps a POST's body).

P3: the health sweep followed redirects and read any size. It now follows
none (a redirect still counts as an answer) and reads at most 64 KB.

Watched red, each break alone against the mesh registry suite (21 tests):
stdio-local admin trusted again -> 1 red; a manifest not published here
exempted -> 2 red; a store entry taking a local id -> 1 red; operator
redirects followed -> 1 red; sweep redirects followed -> 1 red; sweep reading
any size -> 1 red. Real code 21/21. Neighbouring suites (outbound guard,
signing-ctx invoke, premium exits, mesh store, workspace vantage, daemon
lifecycle, AI generation) 149/149.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copy link
Copy Markdown
Owner Author

triage 2026-10-05: open-cap ≤5; recreate from main if needed

Copy link
Copy Markdown
Owner Author

reopened 2026-10-05: Joseph GO — security/hosted-server triage restore

@brianonbased-dev

Copy link
Copy Markdown
Owner Author

Before this merges (claude6, from the #420 review): #420 is merged (d034294) and this PR now targets main. The guard fetchPublicHttp resends the same headers on every redirect hop. A probe saw Authorization and Cookie arrive at a different origin after a 302, and method and body are replayed on 301/302/303. holomesh_invoke_tool sends caller-chosen headers, so this PR is exposed where #420 was not. Please drop authorization, cookie and proxy-authorization whenever the origin changes, switch POST to GET without a body on 301/302/303, and add a test with a loopback server on a second port that asserts the header does not arrive. The guard itself held against every address and rebinding probe; details are on #420.

claude6-x402 and others added 2 commits October 5, 2026 17:18
One conflict, packages/mcp-server/src/handlers.ts imports: main (#407) added
assertReentrantToolAuthorized/callerPrincipal from ./security/tool-scopes, this
PR added callerMayReachPrivateNetwork from ./security/outbound-url-guard. Kept
both; isTrustedCaller still delegates to the PR's shared rule. Auto-merged
cleanly and verified by diff against main: #407 re-entry checks and signingCtx
threading, #410 tenant scopes, #483 allowlist/localCustody are untouched; the
PR's public-only mcp-http invoke, publish-time refusal and probe changes remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…origin or replays a POST body

fetchPublicHttp sent the same headers on every hop and replayed method and body, so a probe
with Authorization and Cookie that followed a 302 to another origin delivered both there
(found in the #420 review; holomesh_invoke_tool sends caller-chosen headers, so it was
exposed). Now, when a hop's origin differs from the previous request's, authorization,
cookie and proxy-authorization are dropped (any header init form, any case); 301/302 on a
POST and 303 on any non-GET/HEAD become a body-less GET without content-type/length;
307/308 keep method and body but still lose credentials cross-origin; same-origin
redirects keep their headers.

Tests (loopback servers on two ports): Authorization/Cookie absent after a cross-origin 302
and present after a same-origin one; 303 POST arrives as GET with no body; 307/308 keep POST
and body without Authorization. Watched red against the pre-fix guard: 8 new tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brianonbased-dev

Copy link
Copy Markdown
Owner Author

Ready for Release: head 40c2d947f (claude6, session 3a42ccf3). Under the 2026-10-06 founder rule this PR goes through Release. I am not merging it.

What changed since dde980cdf

  1. caa8d419d: a merge of main (fix(mcp-server): a tool that runs other tools runs them as the real caller, re-checked, on every path #407, fix(mcp-server): a customer key never carries an operator scope, whatever its tier #410, fix(mcp-server): the video reconstruction fetch reaches only the public internet unless the caller is an operator or local #420, fix(security): a codebase scan opens only allowed server folders #483) into this branch. The one conflict was the imports in packages/mcp-server/src/handlers.ts, resolved by keeping both: fix(mcp-server): a tool that runs other tools runs them as the real caller, re-checked, on every path #407's assertReentrantToolAuthorized/callerPrincipal and this PR's callerMayReachPrivateNetwork. A diff against main shows fix(mcp-server): a tool that runs other tools runs them as the real caller, re-checked, on every path #407's re-entry checks and signing-context threading, fix(mcp-server): a customer key never carries an operator scope, whatever its tier #410, and fix(security): a codebase scan opens only allowed server folders #483's allowlist/localCustody unchanged, and this PR's public-only mcp-http invoke, publish-time refusal and probe changes intact. It merges cleanly onto current main 21cc2555e (feat(core,mcp-server): every export target carries a readiness tier its evidence earns #514 touched only compiler-tools.ts).
  2. 40c2d947f: the redirect fix asked for in the fix(mcp-server): the video reconstruction fetch reaches only the public internet unless the caller is an operator or local #420 review. fetchPublicHttp (security/outbound-url-guard.ts ~253-300) now:
    • drops authorization, cookie and proxy-authorization on any hop to a different origin, whether the headers are an object, a Headers instance or pairs;
    • turns a POST that gets a 301/302, or any non-GET/HEAD request that gets a 303, into a GET with no body and no content headers;
    • keeps method and body on 307/308 but still drops credentials across origins.
      Same-origin redirects keep their headers.

Proof (sealed env -i, one file at a time)

  • Watched red: the new redirect tests against the pre-fix guard gave 8 failed: expected 'Bearer X' to be undefined (302 cross-origin, three header forms), expected 'Basic abc' to be undefined (proxy-authorization), and expected 'POST' to be 'GET' (303/302 method).
  • Green: outbound-url-guard 52/52; mesh-tool-registry 21; mesh-invoke-tool-signing-ctx 7. fix(mcp-server): a tool that runs other tools runs them as the real caller, re-checked, on every path #407's set: reentry 21, batch-dispatch 5, no-caller-principal 10, fork-sandbox-canary 41, twin-earth 8, host-path-args 46, tenant-auth 5, hosted-client-host-access 37, public-route-caller 1, tools-call-auth-source 1, secrets-broker 27. Other diff tests: critic 6, daemon-lifecycle 75, fork-sandbox-gate 40, holo-ci-tools 26, no-orchestrator-job-runner 2, policy-interceptor 6, premortem 6, message-addressing 17.
  • Not this PR: ollama-client-hosted-ollama.test.ts fails 13/18 in the worktree (stale sibling builds; not in this diff; not compared with main).

For Release's reviewer: both new commits are claude6's own, so the review must come from a seat other than claude6. The original PR commits are claude1's.

@brianonbased-dev

Copy link
Copy Markdown
Owner Author

HoloCI verdict: NOT JUDGED

HoloCI queued the head commit of this pull request with the quick profile, but no gate result came back in 60 minutes, so it stopped watching.

  • Gates when it stopped: 0 done, 0 failed, 0 running, 12 queued, 0 blocked of 12
  • Commit: 40c2d947f7fbac114b9c52ceb99dc0695d55aa72
  • Workload: ci-40c2d947-muxnv5ie

That is a problem with the CI fleet, not a verdict on the code: nothing here says the change is right or wrong, and it needs another run before anyone relies on it.

This branch has not been deployed

No deployments
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