Skip to content

feat(network): ADR-107 — network exposure as classified state machine#211

Merged
aaronsb merged 4 commits into
mainfrom
feat/107-network-exposure-state-machine
May 24, 2026
Merged

feat(network): ADR-107 — network exposure as classified state machine#211
aaronsb merged 4 commits into
mainfrom
feat/107-network-exposure-state-machine

Conversation

@aaronsb
Copy link
Copy Markdown
Owner

@aaronsb aaronsb commented May 24, 2026

Summary

Models the combined (protocol × bind × certSource) state as a 9-cell classified state machine. Replaces the implicit 0.0.0.0 Node-default bind with a deliberate, classified network exposure model. Server always starts; 🔴 surfaces a Notice + Debug.error + agent-visible MCP initialize.instructions warning so the LLM client also sees it.

  • ADR: docs/architecture/core/ADR-107-network-exposure-modes-as-a-classified-state-machine.md (Accepted in this branch)
  • Closes / supersedes: Bind MCP server to loopback #208 (community PR from @fa1k3 — preserves the security instinct while keeping the LAN/remote escape hatch the hardcoded approach removed)

What's in this PR

  1. docs/adr — ADR-107 captures the 9-cell table, the "always start / loud warning" stance, agent-visibility design, migration approach, and rejected alternatives.
  2. src/utils/network-classifier.ts — pure classify() + resolveListenHost() + resolveBindAxis() + normalizeBindInput() + classifyFromSettings() + agentInstructionsForVerdict().
  3. src/main.tsbindMode + customBindHost + hasShownBindMigrationNotice settings + one-time post-upgrade Notice + new "Network binding" settings section with live verdict badge, bind dropdown, custom-host field with blur-time alias normalization, and inline red caution on "All interfaces".
  4. src/mcp-server.ts — bind-time host resolution, verdict classification, Notice + Debug.error on 🔴, Debug.warn on 🟡, pushes the agent-visible warning into the server pool.
  5. src/utils/mcp-server-pool.tssetInitializeInstructions() setter, conditional spread into the SDK Server constructor options so the instructions field is only included when the verdict is 🔴.
  6. src/node-mcp-server.ts — hardcoded loopback (no plugin-ref to plumb through; consistent with new default).
  7. styles.css — badge + caution styling.
  8. Tests — 40 unit tests covering all 9 table cells + normalization + 7 integration assertions verifying source-level wiring.

State machine

# Protocol Bind Cert Class
1 http loopback 🟢 OK
2 http custom-non-loopback 🔴 JAIL
3 http all 🔴 JAIL
4 https loopback self 🟢 OK
5 https loopback user 🟢 OK
6 https custom-non-loopback self 🟡 WARN
7 https custom-non-loopback user 🟢 OK
8 https all self 🟡 WARN
9 https all user 🟢 OK

Migration

  • New install → defaults to bindMode: 'loopback', badge shows 🟢, no Notice.
  • Existing install with data.json lacking bindMode → defaults applied (loopback), one-time 20s Notice on first load points the user at the new setting. Flag persisted so the Notice never re-fires.

Test plan

  • npm run build — clean
  • npm run lint — 0 errors (5 pre-existing warnings unchanged)
  • npm test — 291/291 pass
  • Manual — install prerelease build via BRAT, verify:
    • Default install shows 🟢 badge, no migration Notice
    • Toggling to "All interfaces" with HTTP shows 🔴 badge + inline red caution + Notice on server restart + MCP initialize.instructions populated when a client connects
    • Toggling HTTPS with "All interfaces" + self-signed shows 🟡 badge + no Notice
    • Custom field accepts 127.0.0.1 and auto-collapses to Loopback on blur
    • Custom field accepts 0.0.0.0 and auto-collapses to All interfaces on blur
    • Existing install (with prior data.json) shows the one-time migration Notice on first load after upgrade

🤖 Implementation drafted with Claude Code.

aaronsb added 3 commits May 24, 2026 16:44
Models the combined (protocol × bind × cert) state as a 9-cell table
with three verdicts (🟢 OK / 🟡 WARN / 🔴 JAIL). Server always starts;
🔴 surfaces a Notice + log + MCP `initialize` instructions warning so
the LLM client also sees it. New settings: `bindMode` discriminated
enum + `customBindHost`. Migration flips the implicit `0.0.0.0` to a
real `127.0.0.1` default, making ADR-103's "loopback by default"
premise actually true. Inspired by community PR #208 (fa1k3) —
preserves the security instinct while keeping the LAN/remote escape
hatch the hardcoded approach would have removed.
Pure functions consumed by the bind-time guard, settings UI, and MCP
initialize.instructions injector: classify(NetworkState) → Verdict,
resolveListenHost(), resolveBindAxis(), normalizeBindInput(),
classifyFromSettings(), agentInstructionsForVerdict(). Single source
of truth for "how exposed is the current network configuration?".

The 9-cell ADR-107 table maps (protocol × bind × certSource) → one of
🟢 ok / 🟡 warn / 🔴 jail. Custom-input normalization collapses
loopback aliases (127.x.x.x / localhost / ::1) and wildcard aliases
(0.0.0.0 / ::) to the corresponding explicit BindMode so the same
intent expressed three ways converges to one state.

40 unit tests cover all 9 table cells, all normalization paths, the
defensive empty-custom fallback, and the agent-instructions formatter.
Replaces the implicit 0.0.0.0 Node-default bind with a deliberate,
classified network exposure model.

Server: MCPHttpServer.start() now resolves the listen host from
settings (bindMode + customBindHost), passes it to listen(), and
classifies the combined (protocol × bind × certSource) state. On 🔴
fires a 15s Notice + Debug.error; on 🟡 logs Debug.warn; on 🟢 silent.
The verdict-derived agent-visible warning is pushed into MCPServerPool
which conditionally injects it into the MCP initialize.instructions
field for every session created after bind, so an LLM client sees the
warning on its first turn against the server. node-mcp-server.ts (the
unused fallback) is hardcoded to loopback for consistency.

Settings: MCPPluginSettings gains bindMode ('loopback' | 'all' |
'custom') default 'loopback', customBindHost default '', and a
one-shot hasShownBindMigrationNotice flag. Existing installs with a
data.json that predates bindMode see a one-time 20s Notice on next
load pointing them at the new setting; fresh installs are silent.

UI: a new "Network binding" section between server config and the
HTTPS section, with a live colored verdict badge that re-classifies
on every change, the bind dropdown (Loopback / All interfaces /
Custom), an inline red caution under "All interfaces" whose wording
adapts to whether HTTPS is enabled, and a custom-host text field
that normalizes loopback/wildcard aliases on blur (typing 127.0.0.1
collapses the dropdown to Loopback; 0.0.0.0 collapses to All).

Tests: 7 integration assertions verifying the source wires listen()
to the resolved host, pushes verdict-derived instructions into the
pool, conditionally injects on 🔴 only, and that the pool spread-
pattern omits the field on ok/warn.

Inspired by PR #208 (fa1k3) — preserves the security instinct while
keeping the LAN/remote escape hatch the hardcoded approach removed.
@github-actions
Copy link
Copy Markdown

✅ Build succeeded! Artifacts are available in the Actions tab.

@aaronsb
Copy link
Copy Markdown
Owner Author

aaronsb commented May 24, 2026

Self-review

Verdict: Request changes — one ADR-fidelity miss that makes rows 5/7/9 of the table unreachable from the UI, plus a narrow migration race and a missing affordance from the ADR. None block the security goal (the dangerous default is fixed), but row 7 (HTTPS + custom-non-loopback + user cert = 🟢) is the documented happy path for legitimate LAN deployments and the implementation can't reach it.


Blocking

1. userSuppliedCert signal is permanently false in normal use
src/mcp-server.ts:659, src/main.ts:782

Both call sites compute userSuppliedCert as certificateConfig?.selfSigned === false. Tracing through src/utils/certificate-manager.ts:

  • selfSigned is not a cert-provenance flag — it gates the auto-generate fallback at line 276 only when !cert || !key. If the user supplies certPath+keyPath, the cert loads regardless of selfSigned.
  • The settings UI never sets selfSigned: false. DEFAULT_SETTINGS.certificateConfig.selfSigned = true (main.ts:65). grep -rn "selfSigned: false" src/ returns nothing.

Consequence: states 5, 7, 9 (the user-supplied-cert rows) are unreachable from the UI. ADR-107 explicitly calls row 7 "the intended LAN/remote deployment" and row 9 "the intended public deployment". The implementation classifies both as warn/jail. Not a security regression — we over-warn rather than under-warn — but the ADR claims behaviour the code doesn't deliver.

Fix: replace the signal with the actual provenance question:

userSuppliedCert: !!(cfg?.certPath && cfg?.keyPath) && cfg?.autoGenerate !== true

or similar. Add a unit test that exercises rows 5, 7, 9 from a settings shape that the UI can actually produce.


Non-blocking

2. Migration Notice races the apiKey auto-save
src/main.ts:113-125, src/main.ts:312-321

loadSettings() writes data.json (with the new bindMode: 'loopback' default) on first load if apiKey is missing. The migration check then calls loadData() again — which returns the just-saved file with bindMode already present, so isExistingPreBindModeInstall() returns false and the Notice is suppressed.

Narrow: only triggers for users upgrading who also lack an apiKey in their persisted settings. Most existing installs have one. But for the affected slice (e.g. someone who deleted the key, or a very old install), the migration nudge silently never fires.

Fix: capture the pre-bindMode signal inside loadSettings() before any save, e.g. stash this.wasExistingPreBindModeInstall = (raw !== null && raw.bindMode === undefined) once and read that boolean in onload.

3. LOOPBACK_PREFIXES = ['127.'] matches 127.evil.com
src/utils/network-classifier.ts:32, 37

isLoopbackHost('127.evil.com') → true → resolveBindAxis says custom-loopback (badge 🟢), but resolveListenHost returns the raw string and Node resolves via DNS. Defence-in-depth: the blur-normalizer rewrites it back to mode loopback with blank host, so in normal UI flow this is unreachable. Direct data.json edits or backup-restore can still produce it.

Fix: tighten to a numeric check, e.g. /^127(\.\d{1,3}){1,3}$/ instead of startsWith('127.').

4. ADR says 🔴 badge includes a Reconfigure hint; implementation doesn't
src/main.ts:786-788

ADR-107 §"Settings UI": "When the badge is 🔴 it includes a Reconfigure hint pointing at the bind dropdown." Badge currently renders only emoji + label + reason. Either add the hint or amend the ADR — but as shipped they disagree.

5. UX edge: bindMode === 'custom' + empty customBindHost shows 🟢 OK
src/utils/network-classifier.ts:87 + src/main.ts:777-788

resolveBindAxis('custom', '') returns custom-loopback (defensive default in the classifier), so the badge says OK and the bind correctly lands on 127.0.0.1. But the user just selected "Custom address…" and typed nothing — the green badge with no explanation looks like a bug or a stuck UI. Minor; consider showing a "no address — falling back to loopback" hint when in this state.


What's solid

  • Order of operations in MCPHttpServer.start() is correct: classify → resolve host → push instructions to pool → listen(). The pool has the right value before any session can arrive.
  • The spread ...(this.initializeInstructions ? { instructions: this.initializeInstructions } : {}) does what it should — verified against @modelcontextprotocol/sdk v1.x (server/index.js:282 surfaces _instructions in the initialize result).
  • stopMCPServer()pool.shutdown() flushes pooled Server instances, so a verdict change (jail→ok) on restart doesn't leak stale instructions from before.
  • 9-cell classify() table matches ADR-107 exactly; the table-driven test pins it (tests/network-classifier.test.ts:11-39).
  • ADR contract — "server always starts, never refuses to bind" — honoured: nothing gates listen() on the verdict; the verdict only drives Notice/log/instructions side-effects.
  • node-mcp-server.ts hardcoded 127.0.0.1 is the right call for an unwired fallback path that has no settings reference.

Closes/supersedes #208 as planned. Plan: address (1) in this PR before merge; file follow-ups for (2)–(5) if not bundled.

Blocking — userSuppliedCert discriminator:
- Was: certificateConfig.selfSigned === false (never set false in UI;
  rows 5/7/9 of the ADR-107 table unreachable, defaulting to over-warn).
- Now: !!(certPath && keyPath) — actual cert provenance signal.
  Fixed in src/mcp-server.ts and src/main.ts settings UI badge.

Non-blocking #1 — migration Notice race:
- Was: isExistingPreBindModeInstall() re-read data.json after
  loadSettings() may have already persisted bindMode='loopback' as
  part of the apiKey auto-save.
- Now: snapshot raw loadData() before loadSettings(), test the snapshot.

Non-blocking #2 — loopback host matcher tightened:
- Was: startsWith('127.') accepted '127.evil.com'.
- Now: strict dotted-quad regex /^127(\.\d{1,3}){3}$/ with octet range.
  New tests cover hostile inputs (127.evil.com, 127.0.0.1.attacker.tld,
  127.300.0.1, 1270.0.0.1).

Non-blocking #3 — Reconfigure hint:
- ADR-107 specifies the 🔴 badge includes a Reconfigure hint;
  implementation now renders 'Reconfigure: switch the bind address
  below to Loopback, or enable HTTPS.' beneath the verdict reason.

Non-blocking #4 — empty custom host UX:
- When bindMode='custom' && customBindHost is empty, render an
  explicit Notice that the server will fall back to loopback until a
  host is entered (resolves the silent 🟢 OK in that state).

Lint clean, 296/296 tests pass.
@github-actions
Copy link
Copy Markdown

✅ Build succeeded! Artifacts are available in the Actions tab.

@aaronsb aaronsb merged commit 1bd6798 into main May 24, 2026
5 checks passed
@aaronsb aaronsb deleted the feat/107-network-exposure-state-machine branch May 25, 2026 00:06
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