Harden: only enable the Werkzeug debugger on a loopback bind - #5382
Conversation
|
| @@ -20631,6 +20659,38 @@ def _init_data_provider(): | |||
| return None | |||
There was a problem hiding this comment.
The code implements a new _is_loopback_host() function that restricts the Werkzeug debugger to loopback binds only, but this security constraint is not documented in the Local Observability Service blueprint, which does not specify any restrictions on debugger availability or host binding behavior.
| pass # stdout may be closed/redirected on Windows | ||
|
|
||
| if args.debug: | ||
| # Dev mode -- use Flask's reloader | ||
| # Dev mode -- use Flask's reloader. | ||
| # | ||
| # The Werkzeug debugger is only safe behind a loopback bind. With | ||
| # debug=True, any unhandled exception serves the interactive traceback | ||
| # page -- source, local variables, and a (PIN-gated) eval console -- to | ||
| # whoever reached the port. `--debug` is the DEFAULT here, so the user | ||
| # who adds `--host 0.0.0.0` for LAN access (which the banner above | ||
| # advertises) would otherwise publish all of that to the network | ||
| # without ever asking for it. | ||
| # | ||
| # Keep the reloader either way -- that is the part dev mode is for -- | ||
| # and drop only the debugger when the bind is not loopback. | ||
| debugger_ok = _is_loopback_host(args.host) | ||
| if not debugger_ok: | ||
| try: | ||
| print( | ||
| f" Note: debugger off -- {args.host} is not loopback. " | ||
| "Auto-reload stays on." | ||
| ) | ||
| print() | ||
| except (ValueError, OSError): | ||
| # stdout may be closed/redirected on Windows, same as the | ||
| # banner above. Never let a status line stop the server. | ||
| pass | ||
| app.run( | ||
| host=args.host, port=args.port, debug=True, use_reloader=True, threaded=True | ||
| host=args.host, | ||
| port=args.port, | ||
| debug=debugger_ok, | ||
| use_reloader=True, | ||
| threaded=True, | ||
| ) |
There was a problem hiding this comment.
The _run_server() function now conditionally disables the Flask debugger based on the host binding, but this behavior—particularly the security hardening that refuses the debugger on non-loopback addresses—is not documented in any blueprint or requirement.
Blocked on
|
|
| @@ -20631,6 +20659,38 @@ def _init_data_provider(): | |||
| return None | |||
There was a problem hiding this comment.
The code implements a new _is_loopback_host() function that restricts the Werkzeug debugger to loopback binds only for security reasons, but this security constraint on debugger availability based on host binding is not documented in the Local Observability Service blueprint.
| pass # stdout may be closed/redirected on Windows | ||
|
|
||
| if args.debug: | ||
| # Dev mode -- use Flask's reloader | ||
| # Dev mode -- use Flask's reloader. | ||
| # | ||
| # The Werkzeug debugger is only safe behind a loopback bind. With | ||
| # debug=True, any unhandled exception serves the interactive traceback | ||
| # page -- source, local variables, and a (PIN-gated) eval console -- to | ||
| # whoever reached the port. `--debug` is the DEFAULT here, so the user | ||
| # who adds `--host 0.0.0.0` for LAN access (which the banner above | ||
| # advertises) would otherwise publish all of that to the network | ||
| # without ever asking for it. | ||
| # | ||
| # Keep the reloader either way -- that is the part dev mode is for -- | ||
| # and drop only the debugger when the bind is not loopback. | ||
| debugger_ok = _is_loopback_host(args.host) | ||
| if not debugger_ok: | ||
| try: | ||
| print( | ||
| f" Note: debugger off -- {args.host} is not loopback. " | ||
| "Auto-reload stays on." | ||
| ) | ||
| print() | ||
| except (ValueError, OSError): | ||
| # stdout may be closed/redirected on Windows, same as the | ||
| # banner above. Never let a status line stop the server. | ||
| pass | ||
| app.run( | ||
| host=args.host, port=args.port, debug=True, use_reloader=True, threaded=True | ||
| host=args.host, | ||
| port=args.port, | ||
| debug=debugger_ok, | ||
| use_reloader=True, | ||
| threaded=True, | ||
| ) |
There was a problem hiding this comment.
The _run_server() function now conditionally disables the Flask debugger based on the host binding (refusing debugger on non-loopback addresses while keeping the auto-reloader), but this security hardening behavior is not documented in the blueprint.
Visual diffComparing 47 of 70 comparison(s) flagged (>1% pixel diff).
Folder: 891fb4c3e263. Full PNGs also attached as a workflow artefact. Generated by visual-diff bot. Pixel diffs >1% flagged; eyeball the table before merging. This check is non-blocking — fail = bot bug, not a code problem. |
|
PR sweeper: skipping — E2E Gate is blocked on Generated by Claude Code |
|
| return None | ||
|
|
||
|
|
||
| def _is_loopback_host(host): | ||
| """True only when `host` binds the loopback interface alone. | ||
|
|
||
| Decides whether Flask's interactive debugger is safe to switch on. Fails | ||
| CLOSED: anything we cannot positively prove is loopback -- an empty value, | ||
| a hostname we do not resolve, an unparseable literal -- reads as remote and | ||
| turns the debugger off. Being wrong in that direction costs a developer a | ||
| traceback page; being wrong the other way publishes source and an eval | ||
| console to the network. | ||
|
|
||
| Note `0.0.0.0` and `::` are NOT loopback: they are wildcard binds that | ||
| include every routable interface on the machine. | ||
| """ | ||
| if not host: | ||
| return False | ||
| candidate = str(host).strip() | ||
| if candidate.lower() in ("localhost", "localhost.localdomain"): | ||
| return True | ||
| # An IPv6 literal may arrive bracketed, as [::1]. | ||
| if candidate.startswith("[") and candidate.endswith("]"): | ||
| candidate = candidate[1:-1] | ||
| # ...and may carry a zone id, as fe80::1%eth0. | ||
| candidate = candidate.split("%", 1)[0] | ||
| try: | ||
| return ipaddress.ip_address(candidate).is_loopback | ||
| except ValueError: | ||
| # Not an IP literal. We do not resolve hostnames here: a name that | ||
| # resolves to loopback today can resolve elsewhere tomorrow, and DNS | ||
| # is not a thing to trust when the answer decides whether to expose | ||
| # an eval console. | ||
| return False |
There was a problem hiding this comment.
The blueprint does not document the _is_loopback_host() function that implements a security constraint restricting the Werkzeug interactive debugger to loopback binds only, which closes a HIGH-severity bandit B201 finding.
| pass # stdout may be closed/redirected on Windows | ||
|
|
||
| if args.debug: | ||
| # Dev mode -- use Flask's reloader | ||
| # Dev mode -- use Flask's reloader. | ||
| # | ||
| # The Werkzeug debugger is only safe behind a loopback bind. With | ||
| # debug=True, any unhandled exception serves the interactive traceback | ||
| # page -- source, local variables, and a (PIN-gated) eval console -- to | ||
| # whoever reached the port. `--debug` is the DEFAULT here, so the user | ||
| # who adds `--host 0.0.0.0` for LAN access (which the banner above | ||
| # advertises) would otherwise publish all of that to the network | ||
| # without ever asking for it. | ||
| # | ||
| # Keep the reloader either way -- that is the part dev mode is for -- | ||
| # and drop only the debugger when the bind is not loopback. | ||
| debugger_ok = _is_loopback_host(args.host) | ||
| if not debugger_ok: | ||
| try: | ||
| print( | ||
| f" Note: debugger off -- {args.host} is not loopback. " | ||
| "Auto-reload stays on." | ||
| ) | ||
| print() | ||
| except (ValueError, OSError): | ||
| # stdout may be closed/redirected on Windows, same as the | ||
| # banner above. Never let a status line stop the server. | ||
| pass | ||
| app.run( | ||
| host=args.host, port=args.port, debug=True, use_reloader=True, threaded=True | ||
| host=args.host, | ||
| port=args.port, | ||
| debug=debugger_ok, | ||
| use_reloader=True, | ||
| threaded=True, | ||
| ) |
There was a problem hiding this comment.
The blueprint does not specify that the _run_server() function conditionally disables the Flask debugger based on host binding (refusing debugger on non-loopback addresses while preserving the auto-reloader), which is a security hardening that the blueprint's "Entry Points and Boundaries" section should document for #LocalDashboard.
|
blocked on author decision — skipping (auto-mergeability sweep): Drift Bot reports 2 unresolved findings requiring blueprint updates in the 8090.ai factory (Local Observability Service blueprint does not document the Generated by Claude Code |
Correction: #5332 will not unblock this PRThe auto-mergeability sweep above is right that this is blocked on an author decision, and right about which findings. One claim in it is wrong, and worth correcting because acting on it means waiting for something that cannot help:
It will not be. #5332 adds if spec.skip_if_unreported and not matched:
results.append(SpecResult(spec, "passed", "no status reported, treated as skipped"))
continueOn this PR drift-bot is reported and is failing, so That is deliberate, not an oversight. #5332's own docstring says "A failing status still fails the gate — the guard is preserved whenever the reporter does post," and it ships a test pinning exactly this case: def test_a_failed_drift_bot_fails_the_gate():
...
assert result.state == "failed"#5332 targets a different problem: frontend-only PRs (dependabot npm bumps) where the 8090 App never posts, which used to hang the gate for the full timeout. Valuable, but orthogonal to this PR. So the remedy is unchanged, and it is the one thing that moves this: document the loopback-only debugger constraint in the Local Observability Service blueprint — round 3 named the exact spot, its "Entry Points and Boundaries" section, for #LocalDashboard. Worth recording that pushing is now conclusively not the lever here. Drift Bot has run three times on this PR (07:45, 12:17, 15:43) and returned identical findings each time; two of those were free re-rolls from the main merges at 12:16 and 15:42. #5343 is 3-for-3 the same way. Everything else is green: on the current head Generated by Claude Code |
|
Auto-janitor report: E2E Gate (required check) failed on run Generated by Claude Code |
|
Auto-mergeability sweep: blocked by Drift Bot ( Generated by Claude Code |
|
| @@ -20723,6 +20751,38 @@ def _init_data_provider(): | |||
| return None | |||
There was a problem hiding this comment.
The code implements a new _is_loopback_host() function that restricts the Werkzeug interactive debugger to loopback binds only for security (closes bandit B201), but this security constraint and helper function are not documented in the blueprint's "Entry Points and Boundaries" section for #LocalDashboard.
| @@ -20673,9 +20674,36 @@ def _run_server(args): | |||
| pass # stdout may be closed/redirected on Windows | |||
|
|
|||
| if args.debug: | |||
There was a problem hiding this comment.
The _run_server() function now conditionally disables the Flask debugger based on host binding (refusing debugger on non-loopback addresses while preserving the auto-reloader), but this security hardening behavior is not documented in the blueprint's "Entry Points and Boundaries" section for #LocalDashboard.
|
blocked on author decision — skipping (auto-mergeability sweep) Generated by Claude Code |
|
blocked on author decision — skipping (auto-mergeability sweep) Generated by Claude Code |
|
Automated mergeability sweep — 2026-08-31 E2E Gate is failing because the 8090 Software Factory Drift Bot posted a What was tried: Reviewed CI logs, confirmed no GitHub Actions failure — the sole blocker is the Drift Bot What's left: Author needs to open the 8090 Software Factory, review the 2 drift findings for this PR, and either update the code to align with the product requirements/blueprints or update the requirement record. After addressing them, push a new commit (or empty push) to trigger a fresh Drift Bot scan. Generated by Claude Code |
|
| @@ -20723,6 +20751,38 @@ def _init_data_provider(): | |||
| return None | |||
There was a problem hiding this comment.
The code implements a new _is_loopback_host() function that restricts the Werkzeug interactive debugger to loopback binds only for security (closes bandit B201 HIGH), but this security constraint and helper function are not documented in the blueprint's "Entry Points and Boundaries" section for #LocalDashboard.
|
| @@ -0,0 +1,34 @@ | |||
| """Server-startup helpers for dashboard.py.""" | |||
There was a problem hiding this comment.
A new is_loopback_host() security helper function is implemented to restrict Flask's Werkzeug interactive debugger to loopback-only binds (closing bandit B201 HIGH severity), but this security-critical helper and the constraint it enforces are not documented in the Local Observability Service blueprint.
| # and drop only the debugger when the bind is not loopback. | ||
| debugger_ok = _is_loopback_host(args.host) | ||
| if not debugger_ok: | ||
| try: | ||
| print( | ||
| f" Note: debugger off -- {args.host} is not loopback. " | ||
| "Auto-reload stays on." | ||
| ) | ||
| print() | ||
| except (ValueError, OSError): | ||
| # stdout may be closed/redirected on Windows, same as the | ||
| # banner above. Never let a status line stop the server. | ||
| pass | ||
| app.run( | ||
| host=args.host, port=args.port, debug=True, use_reloader=True, threaded=True | ||
| host=args.host, | ||
| port=args.port, | ||
| debug=debugger_ok, | ||
| use_reloader=True, | ||
| threaded=True, | ||
| ) | ||
| else: |
There was a problem hiding this comment.
The _run_server() function now conditionally restricts Flask's Werkzeug interactive debugger to loopback-only host binds by gating debug=True on _is_loopback_host(args.host), closing a HIGH severity security vulnerability (bandit B201), but this security constraint is not documented in the blueprint's specification of the dashboard entry point.
|
✨ auto-fixed: merged latest main into branch (was BEHIND; no conflicts) Generated by Claude Code |
vivekchand
left a comment
There was a problem hiding this comment.
Test plan & review notes
Repo: vivekchand/clawmetry
What changed
- New
helpers/server.pywithis_loopback_host()that gates the Werkzeug interactive debugger to loopback-only binds;dashboard.py::_run_servernow calls it before passingdebug=Truetoapp.run. Reloader stays on regardless. New test moduletests/test_debugger_loopback_only.pyadded to CI.
Smoke commands
# Fast: the new unit tests
python3 -m pytest tests/test_debugger_loopback_only.py -v
# Full suite
make test
# Targeted helper test
python3 -c "
from helpers.server import is_loopback_host
assert is_loopback_host('127.0.0.1') is True
assert is_loopback_host('0.0.0.0') is False # the regression
assert is_loopback_host('::1') is True
assert is_loopback_host('[::1]') is True
assert is_loopback_host('192.168.1.10') is False
print('All assertions passed')
"Likely failure modes from the diff
0.0.0.0with--debugmust NOT enable the debugger — this is the core exposure. The test covers it, but worth a manual smoke withpython3 dashboard.py --host 0.0.0.0 --debugto confirm the startup banner prints the "debugger off" note.localhostand127.0.0.1must still pass (developer UX regression risk).LocalHost(mixed case) must also pass per the parametrized list.- Bracketed IPv6
[::1]and zone-id form::1%lo0— the strip+split logic inis_loopback_hostcovers these; unit tests should catch a regression here. is_loopback_hostonNonemust returnFalsenot raise —test_helper_never_raises_on_hostile_inputcovers this; worth confirming noAttributeErroronNone.strip().dashboard.pyimports_is_loopback_hostfromhelpers.server— a missinghelpers/__init__.pywould silently fail on some packaging paths (thoughhelpers/already has other modules so this is low-risk).
Issue link
- No
Closes #Nfound in the PR body. This looks like a self-contained field-hardening fix; if it was reported internally you may want to add aRefsline so it's traceable.
Generated by Claude Code
|
| @@ -0,0 +1,34 @@ | |||
| """Server-startup helpers for dashboard.py.""" | |||
There was a problem hiding this comment.
A new is_loopback_host() security helper function is implemented to restrict Flask's Werkzeug interactive debugger to loopback-only binds (closing bandit B201 HIGH severity), but this security-critical helper and the constraint it enforces are not documented in the Local Observability Service blueprint.
| # and drop only the debugger when the bind is not loopback. | ||
| debugger_ok = _is_loopback_host(args.host) | ||
| if not debugger_ok: | ||
| try: | ||
| print( | ||
| f" Note: debugger off -- {args.host} is not loopback. " | ||
| "Auto-reload stays on." | ||
| ) | ||
| print() | ||
| except (ValueError, OSError): | ||
| # stdout may be closed/redirected on Windows, same as the | ||
| # banner above. Never let a status line stop the server. | ||
| pass | ||
| app.run( | ||
| host=args.host, port=args.port, debug=True, use_reloader=True, threaded=True | ||
| host=args.host, | ||
| port=args.port, | ||
| debug=debugger_ok, | ||
| use_reloader=True, | ||
| threaded=True, | ||
| ) | ||
| else: |
There was a problem hiding this comment.
The _run_server() function now conditionally restricts Flask's Werkzeug interactive debugger to loopback-only host binds by gating debug=True on _is_loopback_host(args.host), closing a HIGH severity security vulnerability (bandit B201), but this security constraint is not documented in the blueprint's specification of the dashboard entry point.
Third time on this branch: `scripts/gen_module_map.py --check` reports `docs/MODULE_MAP.md is out of date` after a merge of main. The count line in this file is a single line that both this branch and main edit, so every main merge that adds or removes a module resolves to one side's number and leaves the file stale. 230 -> 231 -> 232 across the last three merges; the tree now holds 232. Regenerated with the repo's own generator rather than hand-edited, per the note at the top of the file. The whole diff is that one count. No-PRD: regenerating a generated file to fix a red CI check; no behaviour changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXDmqMhUHi4XYZ7s1Q7kg8
|
| @@ -0,0 +1,34 @@ | |||
| """Server-startup helpers for dashboard.py.""" | |||
There was a problem hiding this comment.
A new is_loopback_host() security helper function is implemented to restrict Flask's Werkzeug interactive debugger to loopback-only binds (closing bandit B201 HIGH severity), but this security-critical helper and the constraint it enforces are not documented in the Local Observability Service blueprint.
| debugger_ok = _is_loopback_host(args.host) | ||
| if not debugger_ok: | ||
| try: | ||
| print( | ||
| f" Note: debugger off -- {args.host} is not loopback. " | ||
| "Auto-reload stays on." | ||
| ) | ||
| print() | ||
| except (ValueError, OSError): | ||
| # stdout may be closed/redirected on Windows, same as the | ||
| # banner above. Never let a status line stop the server. | ||
| pass | ||
| app.run( | ||
| host=args.host, port=args.port, debug=True, use_reloader=True, threaded=True | ||
| host=args.host, | ||
| port=args.port, | ||
| debug=debugger_ok, | ||
| use_reloader=True, | ||
| threaded=True, | ||
| ) | ||
| else: |
There was a problem hiding this comment.
The _run_server() function now conditionally restricts Flask's Werkzeug interactive debugger to loopback-only host binds by gating debug=True on _is_loopback_host(args.host), closing a HIGH severity security vulnerability (bandit B201), but this security constraint is not documented in the blueprint's specification of the dashboard entry point.
|
| @@ -0,0 +1,34 @@ | |||
| """Server-startup helpers for dashboard.py.""" | |||
There was a problem hiding this comment.
A new is_loopback_host() security helper function is implemented to restrict Flask's Werkzeug interactive debugger to loopback-only binds (closing bandit B201 HIGH severity), but this security-critical helper and the loopback-only constraint are not documented in the Local Observability Service blueprint.
| # | ||
| # Keep the reloader either way -- that is the part dev mode is for -- | ||
| # and drop only the debugger when the bind is not loopback. | ||
| debugger_ok = _is_loopback_host(args.host) |
There was a problem hiding this comment.
The _run_server() function now conditionally restricts Flask's Werkzeug interactive debugger to loopback-only host binds by gating debug=True on _is_loopback_host(args.host) (closing bandit B201 HIGH), but this security constraint on the debugger's availability is not documented in the blueprint's specification of the dashboard entry point.
| @@ -0,0 +1,103 @@ | |||
| """The Werkzeug debugger may only come up on a loopback bind. | |||
There was a problem hiding this comment.
A comprehensive test suite with 22 test cases documenting the is_loopback_host() helper and the security constraint restricting Flask's Werkzeug debugger to loopback-only binds is implemented, but this security feature and its behavioral contract are not documented in the Local Observability Service blueprint.
Three things were wrong with this PR, only one of them cosmetic. 1. The 22 tests all exercised is_loopback_host in isolation and none exercised its use. A correct helper that nothing calls closes no vulnerability: restoring app.run(debug=True) left the whole suite green. dashboard.py is now parsed and the app.run() call inside _run_server asserted structurally -- debug is not a hardcoded True, it is a name bound from a loopback check in that same function, and use_reloader stays unconditionally True. Both regressions proven red first: debug=True (the original bug) and debug=False (which passes a naive 'not True' check while removing dev mode's debugger for everyone). Worth recording from that exercise: with debug=True restored, the status line still printed 'debugger off' while the debugger was on. The note and the flag are computed independently, so the message is not evidence. 2. dashboard.py imported ipaddress and never used it -- the helper owns that work now. 3. docs/MODULE_MAP.md had gone stale as main moved under the branch, which is what reddened Syntax & Lint. Regenerated. Drift Bot's three findings were one gap: the constraint was live in code and absent from the Local Observability Service blueprint, which now carries five contracts and the ADR above. helpers/server.py points at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xb6A5G74JiMe3zHFs1JZEP
✅ Drift Bot (ClawMetry): no drift detectedDrift Bot analyzed the changed files against this project's blueprints and requirements and found no drift. |
Claude-Session: https://claude.ai/code/session_01Xb6A5G74JiMe3zHFs1JZEP Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Live in PyPI Downloaded
One finding from the verification worth carrying forward: with No cloud promotion needed: this is the local dashboard's own dev-mode entry point, which the hosted service does not run. |
What this fixes
_run_serverpasseddebug=Truetoapp.runwheneverargs.debugwas set — and--debugdefaults to True here (you opt out with--no-debug).--hostdefaults to127.0.0.1, so the common case was already fine, but nothing tied the two settings together.So passing
--host 0.0.0.0to reach the dashboard from another machine also served Flask's interactive debugger on every interface. That page hands any client that triggers an unhandled exception the full traceback, the source around each frame and every local variable in scope, plus a PIN-gated eval console. The PIN keeps it short of trivial remote code execution — but the source and locals disclosure needs no PIN, and neither is something a user asked for by typing--host.A non-loopback bind is an expected way to run this, not an exotic one: the startup banner prints a LAN URL and a "Public - ensure port is open" URL a few lines above the call.
The change
_is_loopback_hostnow decides whether the debugger comes up; only a loopback bind gets it.--host 0.0.0.0behaves exactly as before minus the debugger, and prints a one-line note saying so.0.0.0.0,::) are correctly not loopback; they include every routable interface.No config, flag, or default changes.
--no-debugand the Waitress production path are untouched.How it was found
bandit
B201(HIGH severity) in the scheduled security audit.Verification
tests/test_debugger_loopback_only.py— 22 cases, all passing: loopback literals across the whole127/8block,localhost(and case/whitespace variants), bracketed ([::1]) and zone-suffixed (::1%lo0) IPv6, both wildcard binds, LAN and routable addresses, hostnames including alocalhost.evil.testprefix trap, and hostile input that must not raise.python3 -m py_compile dashboard.pypasses.ruff check dashboard.py --select E,W --ignore E501,E402(the command CI runs) reports 54 findings before and after — no new lint.scripts/check_py39_annotations.pyandscripts/check_ac_coverage.py --checkboth pass.except (ValueError, OSError)guard the banner above it uses, so a closed/redirected stdout on Windows cannot turn a status line into a failed start.ipaddressis stdlib (3.3+), so this adds no dependency.No-PRD: security hardening of an existing code path — closes a bandit B201 (HIGH) finding by refusing the Werkzeug debugger on a non-loopback bind. No product behaviour a record describes changes: no flag, default, or endpoint moves, and the dev-mode reloader is untouched.
Update: the guard did not cover the thing it was guarding
The original 22 tests all exercised
is_loopback_hostin isolation and none exercised its use. A correct helper that nothing calls closes no vulnerability — restoringapp.run(debug=True)left the entire suite green._run_serveris far too side-effect-heavy to invoke from a unit test (banners, listeners, a real bind), sodashboard.pyis now parsed and theapp.run(...)call inside it asserted structurally. Both regressions that matter fail:The second matters as much as the first:
debug=Falsepasses a naive "notTrue" assertion while silently removing dev mode's debugger for everyone.use_reloader=Trueis separately pinned as unconditional, so the fix cannot cost the feature it protects.Runtime evidence
Captured from the real argparse parser, one fresh process per host, intercepting what
_run_serveractually handsapp.run:Note the last two lines. With
debug=Truerestored the status line still says "debugger off" while the debugger is on — the note and the flag are computed independently. That is now a recorded contract: the message is not evidence of the behaviour, so no guard may accept it as such.The premise checks out too:
--debugisstore_true, default=True(dashboard.py:13697) and--hostdefaults to127.0.0.1but the startup banner advertises the LAN and public URLs, so--host 0.0.0.0is a documented path into the exposure.Also fixed
dashboard.pyimportedipaddressand never used it — the helper owns that work.docs/MODULE_MAP.mdhad gone stale as main moved under the branch, which is what reddenedSyntax & Lint. Regenerated.helpers/server.pypoints at it.