Skip to content

Add proxy-tunnel support - #1836

Draft
Joannis wants to merge 1 commit into
mainfrom
jo/proxy-tunnel
Draft

Add proxy-tunnel support#1836
Joannis wants to merge 1 commit into
mainfrom
jo/proxy-tunnel

Conversation

@Joannis

@Joannis Joannis commented Aug 30, 2026

Copy link
Copy Markdown
Member

TODO: Gate behind entitlement prior to merge

Effectively equivalent to an SSH tunnel

TODO: Gate behind entitlement prior to merge
Copilot AI lite review requested due to automatic review settings August 30, 2026 11:25
@Joannis
Joannis marked this pull request as draft August 30, 2026 11:25
@github-actions

Copy link
Copy Markdown
Contributor

Swift Lint Failed

The Swift linter found issues with your code. Please run the following commands to fix them:

cd swift
make format
Lint issues
WendyAgentCore/Tests/WendyAgentTests/TunnelBrokerClientTests.swift:95:35: error: [AddLines] add 1 line break
WendyAgentCore/Tests/WendyAgentTests/TunnelBrokerClientTests.swift:95:53: error: [AddLines] add 1 line break
WendyAgentCore/Tests/WendyAgentTests/TunnelBrokerClientTests.swift:100:37: error: [AddLines] add 1 line break
WendyAgentCore/Tests/WendyAgentTests/TunnelBrokerClientTests.swift:100:55: error: [AddLines] add 1 line break

Then commit and push the changes.

@github-actions

Copy link
Copy Markdown
Contributor

AI Security Review

Note

Automated security review from Claude. Apply, adapt, silence with // SECURITY: <reason>, or dismiss as needed.

Input coverage: 11/11 changed files; 25,912/25,912 bytes reviewed; diff SHA-256 4eff2141ba7c58455dfca20d696c414bc39cbdef464105eb83a1d97288b46bcd; truncation: none.

Claude found security review findings for this PR.

🚨 Critical — Open CRITICAL: SSRF loopback guard removed from Go agent broker dial handler

go/internal/agent/services/tunnel_broker_client.go:259-320: The agent no longer restricts broker-directed DialRequests to loopback, allowing the cloud broker to drive TCP connections to any host reachable from the device.

Details
**Status:** Open
**Severity:** CRITICAL
**Standards:** SOC2-CC6.1, SOC2-CC6.6, ISO27001-A.8.20, ISO27001-A.8.22, NIST-800-53-SC-7, NIST-CSF-PR.AC
**Location:** `go/internal/agent/services/tunnel_broker_client.go:259-320`

The removed block previously rejected any `req.Host` that was not `localhost` or a loopback IP:

```go
ip := net.ParseIP(req.Host)
if req.Host != "localhost" &amp;&amp; (ip == nil || !ip.IsLoopback()) {
    // reject
}
```

After this change, `req.Host` flows directly into `net.JoinHostPort(req.Host, ...)` and is dialed. Because `req.Host` originates from the CLI/broker and is resolved by the device, a compromised or malicious broker (or any actor able to inject a DialRequest) can pivot the agent into connecting to arbitrary internal LAN services (databases, metadata endpoints, admin panels), turning the device into an SSRF/pivot proxy. `isTunnelLoopbackHost` is now used only to decide port remapping, not to gate the target.

**Remediation:** Do not remove the target restriction unless the device-side reachability is explicitly authorized and entitlement-gated per the PR's own TODO. If remote-host forwarding is a deliberate feature, enforce authorization server-side (org/device policy), add an allowlist/denylist for internal ranges (RFC1918, link-local, metadata IPs like 169.254.169.254), and require explicit per-device opt-in before merging. Do not merge with the guard silently removed.

🚨 Critical — Open CRITICAL: SSRF loopback guard removed from Swift agent broker dial handler

swift/WendyAgentCore/Sources/WendyAgent/Cloud/TunnelBrokerClient.swift:327-345: The Swift agent's handleDial no longer rejects non-loopback dial targets, mirroring the Go SSRF regression.

Details
**Status:** Open
**Severity:** CRITICAL
**Standards:** SOC2-CC6.1, SOC2-CC6.6, ISO27001-A.8.20, NIST-800-53-SC-7
**Location:** `swift/WendyAgentCore/Sources/WendyAgent/Cloud/TunnelBrokerClient.swift:327-345`

The `guard Self.isLoopback(dial.host) else { ... return }` gate was removed and replaced with `tunnelDialPort`, which only affects port remapping. `dial.host` is now used to open a TCP connection to an arbitrary host resolved by the device. This is the same broker-directed SSRF/pivot exposure as the Go path and affects Apple-based WendyOS agents identically.

**Remediation:** Same as the Go finding — retain a target-authorization control, entitlement-gate the remote-host feature, and block internal/metadata address ranges before allowing arbitrary device-resolved destinations.

🛑 Error — Open HIGH: Security-relevant feature merged without its stated entitlement gate

go/internal/cli/commands/cloud_forward.go:22-45: The PR body states the proxy-tunnel must be gated behind an entitlement before merge, but no gating exists in the diff.

Details
**Status:** Open
**Severity:** HIGH
**Standards:** SOC2-CC8.1, ISO27001-A.8.28, ISO27001-A.8.32, NIST-800-53-SA-11, NIST-CSF-PR.PS
**Location:** `go/internal/cli/commands/cloud_forward.go:22-45`

The PR description contains `TODO: Gate behind entitlement prior to merge` and describes the capability as 'Effectively equivalent to an SSH tunnel.' The diff introduces full remote-host forwarding (`&lt;local-port&gt;:&lt;remote-host&gt;:&lt;remote-port&gt;`) and removes the agent-side restriction, yet there is no entitlement/authorization check, feature flag, or policy control anywhere in the changed code. Shipping a device-pivoting capability without authorization boundaries violates least-privilege and change-control expectations.

**Remediation:** Implement the entitlement/authorization gate (server-side, enforced at the broker and/or agent) before merge. Ensure that only explicitly entitled orgs/devices/users can request remote-host targets, and log/audit such requests.

⚠️ Concern — Open MEDIUM: No validation blocks link-local/metadata and internal-range destinations

go/internal/cli/commands/cloud_forward.go:44-101: parseTunnelArg accepts any hostname/IP as a remote target with no allowlist or block of sensitive ranges.

Details
**Status:** Open
**Severity:** MEDIUM
**Standards:** SOC2-CC6.1, ISO27001-A.8.20, NIST-800-53-SC-7, GDPR-Art.32
**Location:** `go/internal/cli/commands/cloud_forward.go:44-101`

The CLI-side `parseTunnelArg` validates only port numbers and bracketed IPv6 syntax; it accepts arbitrary hostnames and IPs (e.g. `169.254.169.254`, RFC1918 addresses). Combined with device-side resolution and the removed agent guard, this permits reaching cloud metadata services and internal infrastructure via the device. Even if CLI validation is defense-in-depth, the primary enforcement must be server/agent-side.

**Remediation:** Add server- and agent-side deny rules for link-local (169.254.0.0/16, fe80::/10), loopback-spoofing, and optionally RFC1918 ranges unless explicitly permitted by device policy. Treat device-resolved hostnames carefully to avoid DNS-rebinding-style bypass of any IP allowlist.

💡 Info — Open LOW: Removed rejection logging reduces SSRF-attempt visibility

go/internal/agent/services/tunnel_broker_client.go:259-270: The error log emitted when a non-loopback dial was rejected is gone, lowering auditability of anomalous dial targets.

Details
**Status:** Open
**Severity:** LOW
**Standards:** SOC2-CC7.2, ISO27001-A.8.15, NIST-800-53-AU-2
**Location:** `go/internal/agent/services/tunnel_broker_client.go:259-270`

Previously a rejected non-loopback dial produced `c.logger.Error("broker dial request rejected: only loopback targets allowed", ...)`. With the guard removed, there is no security event recorded for dials to unexpected/internal targets. Even if remote-host forwarding is intended, the target host should be audit-logged with session and org/device identity.

**Remediation:** Emit an audit log entry (session ID, org/asset ID, resolved host/port) for every remote-host dial, and alert on internal-range targets.
Compliance summary
**SOC 2 (CC6.1/CC6.6/CC8.1):** Removing the loopback SSRF guard weakens logical access boundaries and introduces a device-pivot capability without authorization or change control. **ISO 27001 (A.8.20/A.8.22/A.8.28):** Network segregation and secure-development controls are undermined by unrestricted device-resolved dial targets. **NIST 800-53/CSF (SC-7, PR.AC):** Boundary protection is effectively bypassed. **GDPR Art.32:** Internal services potentially exposing personal data become reachable through the device. The PR's own 'gate behind entitlement prior to merge' TODO is unmet — the change should not merge until authorization and internal-range protections are enforced server- and agent-side.

@github-actions github-actions Bot added risk: high High estimated risk; thoroughly test compatibility and affected workflows api-review Ask Joannis - Changes a public CLI or protobuf API surface labels Aug 30, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The PR description calls out entitlement gating as a pre-merge requirement, but the updated tunnel dialing paths (Go + Swift) don’t implement a corresponding entitlement/feature-flag enforcement.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds “proxy-tunnel” (remote host) support to the Wendy Cloud tunnel feature so the CLI can forward traffic through a cloud-enrolled device to another TCP host reachable from that device, while keeping loopback-only port remapping for the agent’s well-known mTLS port.

Changes:

  • Extend wendy cloud tunnel to accept <local-port>:<remote-host>:<remote-port> (TCP only) and plumb the remote host through the broker open message.
  • Update agent-side tunnel dialing (Go + Swift) to dial the requested host/port, only remapping the agent mTLS port when the target is loopback.
  • Update tests and user docs to cover the new argument forms and behavior.
File summaries
File Description
swift/WendyAgentCore/Tests/WendyAgentTests/TunnelBrokerClientTests.swift Adds unit test coverage for host-aware port remapping logic.
swift/WendyAgentCore/Sources/WendyAgent/Cloud/TunnelBrokerClient.swift Introduces tunnelDialPort and changes tunnel handling to connect to requested host/port (with loopback-only remap).
go/internal/cli/commands/cloud_tunnel.go Adds openBrokerTunnelToHost and factors open-message creation to include remote host.
go/internal/cli/commands/cloud_tunnel_test.go Expands parser tests for <local>:<host>:<port> and adds a test ensuring open messages include the remote host.
go/internal/cli/commands/cloud_forward.go Updates CLI UX, argument parsing to support remote-host forwarding, and logs to reflect target host.
go/internal/cli/commands/cloud_datagram_test.go Updates callsites to the updated parseTunnelArg signature.
go/internal/cli/assets/docs/cloud/tunnel.md Updates conceptual docs and examples to include remote-host forwarding.
go/internal/cli/assets/docs/clients/wendy-cli/commands/cloud/tunnel.md Updates command docs for the new positional <port-forward> argument and examples.
go/internal/agent/services/tunnel_broker_client.go Removes loopback-only TCP dial restriction and adds host-aware port remap helper.
go/internal/agent/services/tunnel_broker_client_test.go Adds tests for loopback detection and remap behavior.
go/internal/agent/services/mesh_service.go Updates comment to clarify mesh dialing remains local-only.
Review details

Suppressed comments (2)

go/internal/agent/services/tunnel_broker_client.go:272

  • PR description says this feature must be gated behind an entitlement before merge, but TCP dial handling now allows arbitrary req.Host targets with no entitlement/feature-flag check. Since DialRequest carries only host/port/protocol, this effectively enables broker-directed network pivoting from the device unless the broker enforces policy elsewhere.
func (c *TunnelBrokerClient) handleDialRequest(ctx context.Context, client cloudpb.TunnelBrokerServiceClient,
	req *cloudpb.DialRequest, devMD metadata.MD) {
	if req.GetProtocol() == cloudpb.TunnelProtocol_TUNNEL_PROTOCOL_DATAGRAM {
		c.handleDatagramDial(ctx, client, req, devMD)
		return
	}

	port := tunnelDialPort(req.Host, int(req.Port), c.mtlsPort)
	addr := net.JoinHostPort(req.Host, fmt.Sprint(port))
	c.logger.Info("dialing local service for tunnel",
		zap.String("session_id", req.SessionId), zap.String("addr", addr))

	tcpConn, err := net.DialTimeout("tcp", addr, 10*time.Second)

swift/WendyAgentCore/Sources/WendyAgent/Cloud/TunnelBrokerClient.swift:350

  • PR description calls out entitlement gating as a pre-merge requirement, but handleDial now connects to the requested host/port without enforcing any local entitlement/feature-flag check. If broker-side policy is incomplete/misconfigured, this becomes a general-purpose TCP proxy through the device.
    /// Serves one broker `DialRequest`: opens a plain TCP connection to the
    /// requested host and port, opens an `AgentTunnel` stream, and runs the two
    /// relay pumps until the session ends.
    private static func handleDial<Transport: ClientTransport>(
        _ dial: Wendycloud_V1_DialRequest,
        client: Wendycloud_V1_TunnelBrokerService.Client<Transport>,
        config: Config,
        metadata: Metadata,
        logger: Logger
    ) async {
        let port = Self.tunnelDialPort(
            host: dial.host,
            requested: Int(dial.port),
            mtlsPort: config.mtlsPort
        )
        let sessionID = dial.sessionID

        do {
            let local = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup)
                .channelOption(ChannelOptions.allowRemoteHalfClosure, value: true)
                .connect(host: dial.host, port: port) { channel in
  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +89 to +91
if host == "" {
return 0, "", 0, false, fmt.Errorf("invalid remote host %q", host)
}
@github-actions

Copy link
Copy Markdown
Contributor

Docs preview

Preview this PR's docs at: https://docs.wendy.dev/branch-jo-proxy-tunnel-a667c052e04540601bc99be2541c2a6c51b656d0/

This comment is updated automatically when the docs preview is redeployed.

@github-actions

Copy link
Copy Markdown
Contributor

Swift E2E Review

🛑 wendy cloud tunnel help usage line no longer matches E2E expectation

The diff renamed the wendy cloud tunnel positional from <local-port>:<remote-port>[/udp] to a generic <port-forward>, which makes the E2E prints command help test fail deterministically on both local-macos and local-ubuntu-24. Either update the test to match the new, more general usage line, or restore a usage string that names the concrete port-mapping forms.

  • Observed usage line: wendy cloud tunnel <port-forward> [flags]
  • Expected substring in test: wendy cloud tunnel <local-port>:<remote-port>[/udp] [flags]

Recommendation: pick one canonical presentation and align both sides. The E2E spec change is the smaller fix; the CLI-side fix (e.g. tunnel <port | local:remote | local:remote-host:remote-port>[/udp]) documents the new capability directly in the synopsis, which the AI review request explicitly asks for.

Details

What broke

overview.json records two deterministic failures, one per target, both on the same test:

  • wendy-cloud-tunnel/prints-command-help on local-macos (attempt 0001, 0.150s)
  • wendy-cloud-tunnel/prints-command-help on local-ubuntu-24 (attempt 0001, 0.137s)

The recording (observations/wendy-cloud-tunnel/prints-command-help/local-macos/0001/recording.md) shows wendy cloud tunnel --help exiting 0 with a fully-populated help block. The failing expectation is the usage line:

Usage:
  wendy cloud tunnel <port-forward> [flags]

The test at swift/WendyE2ETests/Tests/WendyE2ETests/WendyCloudTunnelTests.swift:30-34 asserts:

stdout.contains("wendy cloud tunnel <local-port>:<remote-port>[/udp] [flags]")

Why it changed

The PR introduces optional remote-host forwarding and rewrites parseTunnelArg to accept <port>, <local:remote>, and <local:remote-host:remote-port> (go/internal/cli/commands/cloud_forward.go). As part of that rename, the cobra command was updated:

// go/internal/cli/commands/cloud_forward.go
Use:   "tunnel <port-forward>",
Long:  "Listens on a local port and forwards each connection through the Wendy Cloud tunnel broker. " +
       "Use <port>, <local-port>:<remote-port>, or <local-port>:<remote-host>:<remote-port>. ..."

Cobra renders Use verbatim into the Usage: line, so the assertion in the E2E spec no longer matches.

Recommended fix

Two viable options, in preference order:

  1. Make the synopsis explicit and update the test. Change Use to something like:

    Use: "tunnel <port | local-port:remote-port | local-port:remote-host:remote-port>[/udp]",

    Then update WendyCloudTunnelTests.swift:30-34 to assert on a stable substring like "wendy cloud tunnel <port" or a shorter representative slice. This satisfies the accompanying // AI: review request at WendyCloudTunnelTests.swift:16 ("Flag confusing port-mapping wording…"), because the synopsis alone now conveys all three supported forms instead of the terse <port-forward> placeholder that requires the reader to consult the long description.

  2. Keep <port-forward> and update only the test. Replace the failing #expect in WendyCloudTunnelTests.swift:30-34 with an assertion on "wendy cloud tunnel <port-forward>" (drop the specific mapping shape from the E2E assertion). This is the minimal change to unblock CI but leaves the synopsis less informative and forces users to read the long description to learn any of the three input forms.

Additional observations from the same help output

While reviewing per the // AI: request at WendyCloudTunnelTests.swift:16:

  • The long description is dense and runs together in one paragraph — it packs three input forms, remote resolution semantics, and a TCP-only safety cue into a single sentence group. If option (1) is taken, this is less pressing; otherwise consider splitting into two short paragraphs or a bullet list so users scanning --help in a terminal can find the three forms quickly.
  • No duplicated global flags were observed; --json shows once under Global Flags. Formatting is otherwise clean.
  • No safety cue about privileged local ports (<1024) appears; the spec rejects invalid port mappings before listening covers rejection behavior at runtime, but the help does not mention it. Non-blocking.

Category / confidence

  • Category: CLI regression — help text change vs. E2E assertion drift; no runtime tunnel behavior affected.

  • Confidence: high — root cause is directly visible in the diff (cloud_forward.go Use field) and the recording; both target failures share the exact same expectation mismatch on identical output.

  • Scope: run

  • Reviewer: claude-opus-4-7

  • Confidence: high

  • Locations: go/internal/cli/commands/cloud_forward.go:25, swift/WendyE2ETests/Tests/WendyE2ETests/WendyCloudTunnelTests.swift:30-34

  • Full review: review.claude-opus-4-7/wendy-cloud-tunnel-help-usage-line-no-longer-matches-e2e-expectation.md


Report artifact: swift-e2e-tests.gh33308875462.run.0001

@thombles thombles left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should pass back information to the CLI about different failure modes - connection refused, DNS resolution, timeout, etc.

The security controls are the most interesting part and worth checking closely once implemented.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-review Ask Joannis - Changes a public CLI or protobuf API surface risk: high High estimated risk; thoroughly test compatibility and affected workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants