Replace hand-written f-string + html.escape() rendering in the Host Agent
local console with a module-level Jinja2 Environment configured with
select_autoescape(["html","xml"]). XSS safety now holds by mechanism
rather than per-call discipline — every operator-controlled field
(device name, connection_info, task summary, etc.) is escaped by the
engine uniformly.
Eight templates under host_agent/web/templates/ replace the former
_chrome(), _CSS, escape(), and per-page _xxx_body() helpers: base.html
(header/nav/CSS + {% block body %}), login, dashboard (with the polling
<script> preserved byte-identically inside {% raw %}), devices, account,
history, tasks_list, and task_detail. The task-list and task-detail
templates — added by the just-landed task-execution-progress-visibility
change — were also migrated here rather than left in f-string form,
since this change removes the shared helpers they depended on.
URLs, auth/session/CSRF semantics, redirects, and /api/status JSON are
unchanged. 15 new template tests cover render-smoke, XSS probing, script
byte-identity, and no-autoescape-bypass guards. Tasks 8.1-8.6 (manual
browser verification) remain.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
18 KiB
Context
apps/device-host-agent/host_agent/web/app.py currently renders five HTML pages (login, status dashboard, devices, account, history) by composing f-string templates with a module-level escape() helper (a thin wrapper over html.escape(..., quote=True)). A shared _chrome(title, body_html, session) function acts as a hand-written base template; a _CSS string constant holds the inline stylesheet; the dashboard page embeds a <script> polling block whose JS braces are doubled ({{/}}) to survive f-string parsing.
This shape was an explicit decision in openspec/changes/host-agent-local-console/design.md, titled "Reuse FastAPI + Starlette's HTMLResponse, not a new micro-framework, not Jinja2", justified at the time by a "small page count" assumption (login, dashboard, devices, history) and the mitigation that "a single small escape()-wrapping helper used for every interpolated value" would keep the discipline manageable.
Two things have changed since that decision was made:
- The page count has grown (account was added) and is about to grow again:
openspec/changes/task-execution-progress-visibility(in flight) plans to add three more Host-Agent-side pages (task list, task detail, timeline). The timeline page in particular is a loop-rendered table of externally-influenced text (task summaries, tool calls, scene/element descriptions). - Every operator-controllable field (
device.name,device.driver_type,connection_info, the upcoming task summaries) is one missedescape(...)call away from an XSS sink. The f-string approach offloads escaping discipline onto the author and reviewer of every change toweb/app.py. This is acceptable when there are four pages written by one author in one PR; it stops being acceptable as the page set and author count grow.
device-host-agent's current direct dependencies are device-agent-runtime, device-cloud-platform, fastapi, httpx, uvicorn[standard]. None of these force jinja2 into the resolved uv.lock (starlette's Jinja2Templates is an optional extra that the Host Agent does not currently import), so adding Jinja2 is a genuine new direct dependency, not surfacing a transitively-resolved one.
Goals / Non-Goals
Goals:
- Render every Host Agent console HTML page through a template engine whose autoescape is enabled by configuration, so the XSS-safety of any interpolated value no longer depends on the page author remembering to call
escape(). - Move the rendering shape closer to what
task-execution-progress-visibilitywill need for its task/timeline pages (loops, conditionals, inheritance), so that change does not have to grow the f-string code further and then migrate later. - Preserve the current pages' observable contract byte-for-byte where it matters to a browser or operator: same URLs, same form fields, same redirects, same session/CSRF behavior, same 5-second
/api/statuspolling, same visual look. - Keep the rendering stack boring and single-dependency: one template engine, no static-asset pipeline, no SPA tooling, no new framework.
Non-Goals:
- No new pages, no new routes, no new visual design, no CSS refactor — pages are migrated mechanically.
- No extraction of the inline
<style>into a static.cssfile or pipeline (nostarlette.staticfiles); the CSS block moves intobase.htmland stays inline. A future change can extract it if the stylesheet grows. - No extraction of the inline dashboard polling
<script>into a.jsfile; the JS stays in the template, just no longer f-string-escaped. - No change to
host_agent/web/auth.py(session store, CSRF, PBKDF2 verification). - No change to
HostAgentConfig, the console lifecycle wiring inhost_agent/app.py, or the loopback-vs-non-loopback bind semantics. - No change to the
console/Runtime SPA or thecloud-console/SPA; both keep their Vue3 + Vite toolchains untouched. - No move to Starlette's
Jinja2Templatesclass — the Host Agent already usesHTMLResponse(content=...)and only needs a configuredjinja2.Environmentplustemplate.render(...). Pulling inJinja2Templateswould couple rendering to a Starlette version-specific signature (TemplateResponse(request, name, context=...)changed across versions) for no functional gain. TheEnvironmentis called directly.
Decisions
D1: Jinja2 as the template engine
jinja2 (pure-Python wheel, no C extension, single dependency) is added as a direct dependency of device-host-agent. The rendering code constructs a jinja2.Environment configured with autoescape=select_autoescape(["html", "xml"]) and a FileSystemLoader pointing at a new host_agent/web/templates/ directory.
Alternatives considered:
- Mako / Chameleon / Genshi: smaller ecosystems, less familiarity, more fragile under modern Python toolchains. Jinja2 is the de facto default for Python server-rendered HTML (Django, Flask, FastAPI docs all use or reference it); picking anything else trades familiarity for no concrete benefit at this scale.
- Starlette's
Jinja2Templates: see Non-Goals — rejected because the wrapper buys nothing the Host Agent needs (it is mainly useful when the same app mixes JSON and template responses behind the same routing conventions the Host Agent already has viaHTMLResponse), and its signature has been unstable across Starlette versions. CallingEnvironment.get_template(...).render(...)directly keeps the rendering code version-agnostic and explicit. - stdlib
string.Template+ a custom autoescape wrapper: would reproduce Jinja2 with less ergonomic syntax and no{% for %}/{% if %}/{% extends %}— i.e. would re-introduce the f-string pain with weaker tooling. Rejected. - Stay with f-strings and add a
bandit/semgreprule to flag bare interpolations: lint-based mitigation does not change the mechanism; it adds a rule that has to be maintained and can be bypassed by any non-trivial builder pattern. Rejected in favor of removing the failure mode entirely.
D2: One module-level Environment, FileSystemLoader rooted at host_agent/web/templates/
Templates live in apps/device-host-agent/host_agent/web/templates/:
host_agent/web/templates/
base.html # <!doctype>, <head>, <style>, header/nav, {% block body %}{% endblock %}
login.html # {% extends "base.html" %}
dashboard.html
devices.html
account.html
history.html
A single module-level _ENV = jinja2.Environment(...) is created at import time in host_agent/web/app.py. Jinja2 compiles templates lazily on first get_template() call and caches the compiled bytecode on the Environment for the lifetime of the process, so subsequent renders are dict-lookup + context-bind — no recompilation. The Environment is thread-safe for read-only use after construction; the console's only writes are during Environment(...) construction itself, which happens once.
PackageLoader("host_agent.web", "templates") was considered as an alternative to FileSystemLoader. Rejected because PackageLoader relies on importlib.resources semantics that interact awkwardly with editable/uv sync installs during local development (template changes not picked up without reinstall); FileSystemLoader(__file__).parent / "templates" resolves correctly under both wheel install and editable workspace layout, and is the pattern Jinja2's own documentation recommends for application code.
D3: autoescape=select_autoescape(["html", "xml"]) rather than autoescape=True
select_autoescape(["html", "xml"]) enables autoescape for templates whose names end in .html or .xml and disables it for others. This is the recommended default in Jinja2's documentation. It provides:
- Automatic HTML escaping for every
{{ value }}interpolation in.htmltemplates, so the spec's XSS-safety property holds without author discipline. - An opt-out path for the rare case where a template intentionally produces HTML markup from trusted Python-side builders (e.g. a pre-rendered fragment) — that case uses
{% autoescape false %}...{% endautoescape %}or| safefilter. No current page needs this; the option is documented for future authors.
autoescape=True (always on) was considered and rejected because it would prevent any future .txt/.csv/.json-flavored template (none planned today, but the option is preserved at zero cost).
D4: base.html replaces _chrome() and _CSS
The current _chrome(title, body_html, session) function becomes base.html:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>{% block styles %}/* …current _CSS content… */{% endblock %}</style>
</head>
<body>
<header>
<strong>Host Agent Console</strong>
{% block nav %}{% if session %}
<nav>…same nav as today…</nav>
{% endif %}{% endblock %}
</header>
<main>
{% block body %}{% endblock %}
</main>
</body>
</html>
Each page template starts with {% extends "base.html" %} and overrides {% block body %} (and optionally {% block nav %} or {% block styles %} for the rare page that needs to). The login page overrides {% block nav %} to empty (matching today's _login_page behavior where session=None).
The module-level _CSS, _chrome, and escape helpers in host_agent/web/app.py are removed in the same change; they are fully subsumed by base.html + autoescape.
D5: Dashboard inline <script> moves into the template under {% raw %}
The dashboard's 5-second /api/status polling script currently lives inside _dashboard_body as an f-string, with every JS { and } doubled to {{/}} to escape from f-string interpolation. In Jinja2 the same collision exists ({{ ... }} is Jinja2 expression syntax). The script moves verbatim into dashboard.html wrapped in {% raw %}...{% endraw %}, which instructs Jinja2 to pass the content through without parsing.
Alternative considered: extract the script into a separate .js file served via starlette.staticfiles. Rejected for this change because (a) it would require a static-asset mount on the FastAPI sub-app, which D1's Non-Goals explicitly defers, and (b) the script is small and tightly coupled to the dashboard's DOM. If the script grows, the extraction can happen in a later change.
A regression assertion in tasks.md verifies the script body is byte-identical to the pre-migration version (modulo whitespace).
D6: Route handlers call _ENV.get_template(name).render(context) and return HTMLResponse(content=...)
Each existing f-string handler becomes:
@app.get("/devices", response_class=HTMLResponse)
async def devices_page(request: Request, session: SessionState = Depends(require_session)):
devices = await asyncio.to_thread(config_store.list)
edit_id = request.query_params.get("edit")
edit_record = await asyncio.to_thread(config_store.get, edit_id) if edit_id else None
html = _ENV.get_template("devices.html").render(
session=session,
csrf_token=session.csrf_token,
devices=devices,
edit_record=edit_record,
error=None,
)
return HTMLResponse(html)
The shape mirrors the current code as closely as possible — same asyncio.to_thread calls, same data fetched, same single HTMLResponse return — so the diff is dominated by "swap f-string for render(...)" and reviewing it stays mechanical.
TemplateResponse (Starlette's helper) is not used; see D1's Non-Goals discussion. HTMLResponse(content=html) is what the code calls today and keeps working.
D7: Forms and CSRF tokens keep using autoescape
CSRF tokens are random secrets.token_urlsafe() strings and contain no HTML metacharacters, so autoescape is a no-op on them semantically. But the spec requirement ("every interpolated field is escaped by mechanism") applies uniformly; CSRF tokens go through the same {{ csrf_token }} interpolation as everything else. This keeps the rule simple and means a future change to the token alphabet (e.g. including = padding) cannot silently introduce an escaping bug.
D8: No template rendering inside host_agent/web/auth.py
auth.py deals with session tokens, CSRF, and PBKDF2 verification. It returns booleans, tokens, and SessionState objects to app.py, never HTML. That boundary is preserved: app.py is the only module that touches the Environment. This keeps the autoescape contract easy to audit — anything that renders HTML is in one file, and every render call goes through the same configured Environment.
Risks / Trade-offs
- [Risk] A new direct dependency (
jinja2) widensdevice-host-agent's supply chain. → Mitigation: Jinja2 is pure-Python, single wheel, maintained by the Pallets organization (same as Flask/Sphinx/Werkzeug), no C extension, no transitive runtime dependencies beyondmarkupsafe(already transitively present via fastapi/starlette).uv.lockgains one direct package + zero new transitive runtime packages. - [Risk] First-render compile cost on each template (sub-millisecond). → Accepted: it is paid once per process lifetime, cached thereafter, and the console is a low-traffic single-operator page. No measurable impact on startup time or request latency.
- [Risk] A subtle behavior change slips in during the mechanical migration (a
escape()call forgotten, a| safefilter introduced, a conditional inverted). → Mitigation: tasks.md requires a regression test that takes the pre-migration HTML of each page (captured via the existing test snapshot or by running the current code) and asserts the post-migration HTML is structurally equivalent; an XSS probe test injects<script>alert(1)</script>into every operator-controlled field and asserts the escaped form appears in the output. - [Trade-off] The dashboard's
<script>is now inside a{% raw %}block rather than extracted into a static asset.] → Accepted: keeps the change scope tight (no static-asset mount) and the script co-located with the DOM it manipulates. Trade-off recorded for future revisit if the script grows. - [Trade-off] The original
host-agent-local-consoledesign's explicit decision against Jinja2 is hereby superseded for the rendering-mechanism question only.] → Mitigation: recorded as an Open Question to be reconciled whenhost-agent-local-consoleis archived. Until then, this change's spec captures the autoescape requirement as a new capability (host-agent-console-template-rendering) rather than a MODIFIED delta against an unarchived pending spec, matching the precedent set bytask-execution-progress-visibilityandcloud-planner-proxywhen they needed to referencehost-agent-local-console/ai-planner-runtimebefore those were archived.
Migration Plan
- Add
jinja2>=3.1toapps/device-host-agent/pyproject.toml'sdependencies. Runuv lock --checkafteruv lockto confirm the lockfile is in sync. Confirmmarkupsafeis the only new transitive package. - Create
host_agent/web/templates/withbase.htmland one file per current page. Capture the byte-level structure of each current page (login, dashboard including the inline script, devices, account, history) before migration as a reference for the regression assertion. - In
host_agent/web/app.py, construct the module-level_ENVand rewrite each route handler per D6. Remove_chrome,_CSS,_login_page,_dashboard_body,_devices_body,_account_body,_history_body, and theescapehelper. - Add
apps/device-host-agent/tests/host_agent/web/test_templates.pycovering: (a) each template renders without raising for representative contexts; (b) the XSS probe appears HTML-escaped in every rendered page that interpolates an operator-controlled field; (c) the dashboard<script>body is byte-identical to the pre-migration reference. - Run the existing Host Agent test suite (
uv run --package device-host-agent pytest) and the root non-integration suite; confirm no regressions. - Run
ruff check --fix,ruff format, andpython -m compileallover the changed files. - Run
openspec validate host-agent-console-jinja2-templates --strict. - Manual browser verification: log in, view status dashboard auto-refresh, add/edit/remove a device, change password, view history, log out. Confirm no visual or behavior change versus the pre-migration pages.
- Rollback: revert the commit. The change introduces no on-disk state format change (templates are code, not data) and no schema migration. The only artifact a previous run may leave is the same SQLite history file the unchanged code would have written; it is unaffected.
Open Questions
- Coordination with
task-execution-progress-visibility. That change's design.md D3 currently commits to "reuseapi/console.py's query patterns and f-string +html.escape()convention" for its new task list/detail/timeline pages. After this change lands, those pages SHOULD be written directly against the Jinja2 templates introduced here (extendingbase.html, looping over timeline entries with{% for %}). The two changes do not conflict at the code level (different files withinhost_agent/web/), but the D3 wording intask-execution-progress-visibility/design.mdwill need a follow-up edit. Options: (a) edit D3 intask-execution-progress-visibilityonce this change is onmaster; (b) defer the wording update until that change is next revised. Either is acceptable; the decision does not block this change. - Supersession of
host-agent-local-console's "not Jinja2" decision. Whenhost-agent-local-consoleis archived intoopenspec/specs/host-agent-local-console/spec.md, this change'shost-agent-console-template-renderingcapability becomes a sibling rather than a delta. The reconciler should decide at archive time whether to fold the autoescape requirement intohost-agent-local-console's spec directly (and retirehost-agent-console-template-renderingas a standalone capability) or keep it as a sibling. Recorded for the archive step; not a blocker for this change. - Whether to introduce Starlette's
Jinja2Templatesfor consistency with the broader FastAPI ecosystem. Currently rejected (D1, Non-Goals). Revisit if a future change adds request-scoped rendering concerns (flash messages, per-request template overrides) thatJinja2Templateswould simplify.