Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,35 @@ Approve/Decline cards in the web UI.
| `gateway.ssl.cert` | path | - | SSL certificate path |
| `gateway.ssl.key` | path | - | SSL private key path |

### Favicon

Drop `favicon.svg`, `favicon.png` or `favicon.ico` into `workspace/config/` and
the gateway serves it at `/favicon.ico`. There is no setting: the file is tracked
config like anything else under `config/`, so a config repo carries it to every
instance that syncs, and a fleet can be told apart by its browser tabs. Nothing
is copied into the web bundle — the file is read per request, so one that arrives
by sync appears without a restart, and removing it goes back to the browser
default.

Use one. If several are present the first of `.svg`, `.png`, `.ico` wins.

A symlink is followed only while it stays inside `workspace/config/`. Git tracks
symlinks, and `/favicon.ico` is served without authentication because a browser
asks for it before anyone logs in — so a `config/favicon.png` pointing at
`/etc/shadow` would otherwise be readable by anyone who can reach the port. One
pointing at another file under `config/` is fine.

An SVG goes out under `Content-Security-Policy: default-src 'none'` — with
`img-src data:` and `style-src 'unsafe-inline'` so ordinary icons still render —
and every format gets `X-Content-Type-Options: nosniff`. Navigating straight to
`/favicon.ico` makes an SVG a same-origin *document* rather than an image, and a
`<script>` inside one would otherwise run there and could read the session token
out of `localStorage`. Reviewing an icon for embedded script is not a reasonable
thing to ask, so the policy does it instead.

An agent can propose an SVG through `propose_config_change`, since proposals
carry text. A `.png` or `.ico` has to be committed by a human.

## Telegram

| Key | Type | Default | Description |
Expand Down
74 changes: 74 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,80 @@ def workspace_settings_file(workspace: Path) -> Path:
return workspace_config_dir(workspace) / "settings.yaml"


# Filenames served at /favicon.ico when one is present in the tracked config
# subtree, best format first. Only one should exist; the order decides it if
# several do.
#
# A convention rather than a setting. The file has to travel with the config repo
# to be worth anything — a fleet pointed at one repo is exactly the case where
# telling the instances apart in a tab bar matters — and the config subtree is
# already synced, already reviewed, and already the surface a proposal may touch.
# A path setting would add a second thing to keep in step with the file, and a
# machine-local path in shared settings is the mistake `gateway.ssl.cert` is
# documented as: a box without that file. There is nothing here to misconfigure.
_FAVICON_FILES: tuple[tuple[str, str], ...] = (
("favicon.svg", "image/svg+xml"),
("favicon.png", "image/png"),
("favicon.ico", "image/x-icon"),
)

# Sent with the favicon, and the reason SVG can be on the list above.
#
# An SVG fetched through ``<link rel="icon">`` is an image and scripts in it do
# not run, but the same URL *navigated to* is a same-origin document, and there
# they do — the standard stored-XSS shape for uploaded SVG. The token this UI
# authenticates with lives in ``localStorage``, so a script reaching that origin
# reaches the session.
#
# What makes it worth a header rather than a note: an agent may propose a favicon
# (SVG is text, so it fits through ``propose_config_change``), and the effect
# classifier there judges by what a file causes the daemon to *run*, which is
# nothing — so a reviewer is shown a graphic with no notice attached. Reviewing
# an icon for embedded script is not a thing to ask of them.
#
# ``default-src 'none'`` stops script and every outbound request; ``img-src
# data:`` keeps an embedded raster working, since design tools emit them; and
# ``style-src 'unsafe-inline'`` keeps ordinary SVG styling working. ``nosniff``
# is for the raster formats: it stops a ``favicon.png`` that is really HTML from
# being re-interpreted as HTML.
FAVICON_RESPONSE_HEADERS: dict[str, str] = {
"Content-Security-Policy": (
"default-src 'none'; img-src data:; style-src 'unsafe-inline'"
),
"X-Content-Type-Options": "nosniff",
}


def workspace_favicon(workspace: Path) -> tuple[Path, str] | None:
"""The tracked favicon and its content type, or ``None`` if there is none.

Answered per call rather than cached at start-up, so a favicon that arrives
by sync — or is simply dropped in — is served without a restart.

The candidate is resolved and required to stay inside the config subtree,
which matters more here than the feature's size suggests. Git tracks
symlinks, so a config repo can carry ``config/favicon.png ->
/etc/shadow``; the route that serves this is unauthenticated by design,
since a browser asks for a favicon before anyone logs in. Following the link
would turn "set your own favicon" into an unauthenticated read of any file
the daemon can open, and a reviewer skimming a config PR would see a
plausible filename.
"""
config_root = workspace_config_dir(workspace)
for name, content_type in _FAVICON_FILES:
candidate = config_root / name
if not candidate.is_file():
continue
if not _is_within(candidate, config_root):
logger.warning(
"Ignoring %s: it resolves outside the tracked config subtree %s",
candidate, config_root,
)
continue
return candidate, content_type
return None


def _load_workspace_settings(workspace: Path) -> dict[str, Any]:
"""Load ``workspace/config/settings.yaml``.

Expand Down
30 changes: 29 additions & 1 deletion nerve/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,32 @@ async def ws_broadcast(session_id: str, message: dict):
async def health():
return {"status": "ok", "version": "0.1.0"}

# Favicon from the tracked config subtree (see config.workspace_favicon).
# No auth: a browser asks for this before anyone has logged in, so requiring
# a token would mean the login page never has an icon.
#
# Before the static mount for the same reason as /health, and the reason is
# not cosmetic here: the SPA catch-all answers every unmatched path with
# index.html, so /favicon.ico currently returns HTML with a 200 and the
# browser is left to make sense of markup it asked for an image.
#
# Registered whether or not the frontend has been built. The favicon is
# config, not build output, and an instance serving the API without a bundled
# UI can still be someone's browser tab.
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
from fastapi.responses import FileResponse, Response

from nerve.config import FAVICON_RESPONSE_HEADERS, workspace_favicon

found = workspace_favicon(get_config().workspace)
if found is None:
return Response(status_code=404)
path, content_type = found
return FileResponse(
str(path), media_type=content_type, headers=FAVICON_RESPONSE_HEADERS,
)

# Serve static web UI files if built
web_dist = Path(__file__).parent.parent.parent / "web" / "dist"
if web_dist.exists():
Expand All @@ -973,7 +999,9 @@ async def health():
# SPA catch-all: serve index.html for any non-API, non-asset route
@app.get("/{path:path}")
async def spa_fallback(path: str):
# Serve actual files if they exist (favicon, etc.)
# Serve actual built files if they exist (robots.txt, manifest, ...).
# Not the favicon: that has its own route above and is served from
# tracked config rather than the bundle, so it never gets here.
file_path = web_dist / path
if file_path.is_file():
return FileResponse(str(file_path))
Expand Down
2 changes: 2 additions & 0 deletions nerve/templates/config-repo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ holds:

- `config/settings.yaml` — shareable settings (secrets are `${ENV_VAR}` refs)
- `config/cron/` — cron jobs, system jobs, and drop-in gate plugins
- `config/favicon.{svg,png,ico}` — optional; served at `/favicon.ico`, so every
instance syncing this repo is identifiable in a browser tab
- `skills/<id>/SKILL.md` — skills

## How changes are made
Expand Down
6 changes: 6 additions & 0 deletions nerve/templates/skills/nerve-workspace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ dropped quietly. Fix the reported paths and re-submit.
`skills/<id>/scripts/` **is** proposable — it's a normal part of a skill — so a
script there reaches the instance through this route like anything else.

Proposals carry **text**, so a binary file cannot go through this tool at all.
That matters for one thing in practice: the instance's favicon
(`config/favicon.svg` / `.png` / `.ico`, served at `/favicon.ico`). You can
propose an `.svg`, since SVG is text. A `.png` or `.ico` has to be committed by a
human — say so rather than submitting something that will be rejected.

### Why this exists, and what it isn't

So that a change to your configuration is reviewed, attributable, and visible in
Expand Down
Loading
Loading