Skip to content

fix(transport): enforce a shared loopback-only plaintext HTTP policy (#1319) - #1344

Open
larry-zy wants to merge 10 commits into
oceanbase:masterfrom
larry-zy:fix/1319-transport-policy
Open

fix(transport): enforce a shared loopback-only plaintext HTTP policy (#1319)#1344
larry-zy wants to merge 10 commits into
oceanbase:masterfrom
larry-zy:fix/1319-transport-policy

Conversation

@larry-zy

Copy link
Copy Markdown
Contributor

Background / Motivation

Closes #1319

PowerContext has several surfaces that open or configure HTTP connections
(the Python Client, the CLI, the Server, and the Agent plugins), but each one
implemented the "plaintext HTTP is only trusted on loopback" rule on its own,
with inconsistent behaviour that could be bypassed or misjudged. This PR
consolidates that rule into one shared implementation and fixes two bugs found
along the way.

What changed

1. Unified transport safety policy (transport.py)

  • Plaintext HTTP is trusted only on loopback addresses; bearer tokens are never
    sent over an unencrypted non-loopback connection; an unauthenticated Server
    must not bind to a routable address without an explicit opt-in.
  • Client / CLI / Server now all route through the shared is_loopback_host /
    is_plaintext_non_loopback helpers.

2. Fix loopback detection that only matched three named hosts

  • The old check matched only 127.0.0.1 / ::1 / localhost, so any other
    address in 127.0.0.0/8 (e.g. 127.0.0.2) was wrongly classified as
    non-loopback and its plaintext requests were rejected.
  • Detection now uses ipaddress.ip_address(host).is_loopback, covering the
    whole IPv4 loopback block.
  • Both Agent plugins (Codex / Claude Code) ship isolated and cannot import
    powercontext, so each vendors its own copy of the check; new parametrised
    drift-guard tests pin both copies to the shared contract.

3. Fix false positive of the token guard for caller-supplied transports

  • The "don't send a bearer token over non-loopback plaintext" guard also fired
    for a caller-supplied http_client, which broke the authenticated e2e flow
    that uses an in-process httpx.ASGITransport (base_url=http://testserver)
    with a token.
  • When the caller supplies its own transport, transport security is the
    caller's responsibility (ASGI in-process, a Unix socket, or a TLS-terminating
    proxy all carry an http:// label, so the scheme is no longer a reliable
    signal). The guard now applies only to the transport the client opens itself.

4. Friendlier CLI error

  • When server run fails to load/validate ServerSettings, it now raises a
    typer.BadParameter instead of a raw traceback.

Testing

  • uv run pytest: 738 passed, 9 skipped
  • uv run ruff check: clean
  • Added/extended: loopback detection (incl. 127.0.0.2, 127.1.2.3),
    Client/Server policy, parametrised drift guards for both plugins, and a case
    allowing a token over a caller-supplied transport.

Notes

  • The Agent plugin pi (TypeScript) has the same isLoopback bug; it is left
    out of this PR and will be handled separately.
  • bub / hermes / openclaw currently do not enforce loopback-only plaintext;
    that is existing design and out of scope here.

…r and Client

The ready-to-run Server and the primary Python Client did not enforce the
network safety policy already applied by the Agent integrations, allowing two
unsafe transports:

- ServerSettings accepted a non-loopback bind (e.g. 0.0.0.0) while bearer
  authentication was disabled, silently exposing an unauthenticated Server.
- ClientSettings / PowerContextClient accepted a non-loopback http:// URL and
  would send a bearer token over plaintext.

Introduce a single shared transport-policy module (powercontext.transport)
and consume it everywhere:

- ClientSettings rejects non-loopback plaintext http:// Server URLs.
- PowerContextClient refuses to send a bearer token over an unencrypted
  non-loopback connection.
- ServerSettings rejects an unauthenticated non-loopback bind unless the
  operator opts in via POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK
  (for TLS-terminated / controlled-network deployments).
- The `server run` CLI applies the same guard, since model_copy bypasses the
  settings validator on --host overrides.

Add a cross-surface contract test suite and update the EN/ZH configuration
reference.

Closes oceanbase#1319
# Conflicts:
#	docs/en/docs/reference/configuration.md
#	docs/zh/docs/reference/configuration.md
#	src/powercontext/server/settings.py
Load the Codex plugin's settings module by path and assert its private
loopback host set and _http_base_url behavior match the shared transport
contract, so the isolated plugin copy cannot silently drift. Tighten the
configuration docs to note the plaintext-URL rule applies to configured
Server URLs.
The loopback check only matched the three named hosts 127.0.0.1, ::1 and
localhost, so plaintext HTTP to any other 127.0.0.0/8 address (e.g.
127.0.0.2) was wrongly rejected as non-loopback. Detect loopback via
ipaddress.ip_address(...).is_loopback, which covers the whole IPv4
loopback block, and route the Client CLI through the shared helper.

Both agent plugins ship isolated and cannot import powercontext, so each
vendors its own copy of the check; parametrised drift-guard tests pin both
copies to the shared contract. Surface ServerSettings validation errors as
a typer.BadParameter instead of a raw traceback.
…ports

The bearer-token-over-plaintext guard is only meaningful for the transport
the client opens itself, where base_url's scheme reflects what crosses the
wire. A caller-supplied http_client owns its own transport (ASGI in-process,
a Unix socket, a TLS-terminating proxy -- all carrying an http:// label), so
the scheme is no longer a reliable signal and enforcing it only produced
false positives, breaking the authenticated in-memory e2e flow. Enforce the
guard only when the client creates the transport.
The Docker image binds POWERCONTEXT_SERVER_HTTP_HOST=0.0.0.0 so the port is
reachable from the harness container, but the unauthenticated-non-loopback
guard now refuses that bind and the Server fails to start. The Compose
network is isolated, so opt in via
POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK to restore startup.
@larry-zy
larry-zy force-pushed the fix/1319-transport-policy branch from 8448ed2 to 8615e7d Compare August 25, 2026 08:24
Comment thread src/powercontext/client/client.py Outdated
Comment thread src/powercontext/server/settings.py
Comment thread src/powercontext/server/cli.py Outdated
Comment thread tests/test_transport.py
 review

- client: gate the plaintext-token guard on an explicit trust_transport_security
  opt-in; supplying an http_client is no longer treated as evidence the transport
  is safe, so a bearer token is still refused over unencrypted non-loopback HTTP.
- docker: declare POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK in the
  image itself so the documented `docker run` starts out of the box, document the
  network-exposure trade-off, and drop the masking env from the e2e compose so it
  exercises the real image contract; add a Dockerfile smoke test.
- server cli: merge --host/--port before validation so a safe override can repair
  an unsafe environment bind, and translate the actionable settings failures
  (unauthenticated non-loopback bind, missing bearer token) into friendly CLI
  errors recognised by exception identity rather than raw validation text.
- cli: unpack the ServerSettings kwargs so the partial ``http`` mapping is not
  type-checked against the full HttpConfig annotation (ty invalid-argument-type).
- tests: pin a wide COLUMNS in the friendly-error tests so the rich panel does
  not hard-wrap the asserted --host / env-var tokens on a narrow CI terminal.
…ntract

Pi's isLoopback only accepted localhost, 127.0.0.1 and [::1], so its production
resolveConfig rejected http://127.0.0.2:8000 as "must use HTTPS outside loopback"
while the shared policy and the Python plugins already trust the whole 127.0.0.0/8
block. Rewrite it to mirror is_loopback_host (strip brackets, lowercase, accept
127.0.0.0/8, localhost and ::1).

Add a Pi Vitest drift guard and a JSON fixture of loopback/non-loopback host
vectors that both the Python drift guard and the Pi suite consume, so a future
divergence from the shared contract fails in at least one language.
…error assertions

pretty-format-json rewrites the fixture with one array element per line; apply that
formatting so the quality hook passes in CI.

The friendly-error CLI tests asserted tokens (--host, the opt-in env var) against
typer's rich error panel. Under GitHub Actions typer forces force_terminal=True and
the runner's TERM=dumb pins rich to 80 columns while ignoring COLUMNS, so the panel
hyphen-breaks --host and force-splits the long env var. Replace the COLUMNS override
with a fixture that neutralises the forced terminal and sets an explicit wide width,
making the assertions independent of the terminal environment.
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.

bug: network transport policy permits unsafe remote HTTP configurations

3 participants