Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions changes/177.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**Sentient no longer opens itself to your whole network on its own.** Listening beyond this computer for devices
now needs its own opt-in (`nodes.lan_bind_all`), and Settings and `sentient doctor` warn while it is on.
2 changes: 1 addition & 1 deletion desktop/src/features/devices/AddDeviceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ function AddDeviceBody({ close }: { close: () => void }) {
const setLan = async (enabled: boolean) => {
setSavingLan(true)
try {
if (!previewMode()) await api.config.patch({ nodes: { lan_enabled: enabled } })
if (!previewMode()) await api.config.patch({ nodes: { lan_enabled: enabled, lan_bind_all: enabled } })
await qc.invalidateQueries({ queryKey: deviceKeys.lan })
newCode()
} catch (e) {
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/features/settings/sections/Advanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export function AdvancedSection({ query }: SectionProps) {
{backend.state === 'ready' ? (socket === 'open' ? 'Running · live connection open' : 'Running · reconnecting') : backend.state}
</span>
</FormRow>
{config.data?.nodes?.lan_enabled && config.data?.nodes?.lan_bind_all && (
<FormRow
label="LAN listener"
description="Devices on your network can reach Sentient. They still have to pair first."
>
<span className="flex items-center gap-2 text-sm text-fg-muted">
<StatusDot tone="warning" />
Exposed to your network
</span>
</FormRow>
)}
<FormRow label="Restart engine" description="Stops and starts Sentient's background engine. Chats in progress are interrupted.">
<Button
size="sm"
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,15 @@ export interface SentientConfig {
proactivity: ProactivityConfig
voice: VoiceConfig
ui: UIConfig
nodes?: NodesConfig
}

export interface NodesConfig {
enabled: boolean
lan_enabled: boolean
lan_port: number
/** Bind the LAN listener to 0.0.0.0 instead of loopback. */
lan_bind_all?: boolean
}

export type ConfigSection = keyof SentientConfig
Expand Down Expand Up @@ -1431,6 +1440,8 @@ export interface DeviceLanInfo {
web_urls?: string[]
fingerprint: string | null
mdns?: boolean
/** True when the listener accepts connections from the whole network (`nodes.lan_bind_all`), false for this computer only. */
bind_all?: boolean
error?: string | null
}

Expand Down
4 changes: 3 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -2668,7 +2668,9 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a
its voice chats and what was learned in them → `{ok, forgotten: ForgetCounts}`, section 7, #153);
`POST /api/nodes/{id}/invoke` `{capability, params, timeout_ms?}` → `{ok, data?, error?, code?}` with `code` `offline`, `unsupported`,
`timeout`, `not_allowed` (desktop screen capture off), `bad_upload` or the device's own code;
`GET /api/nodes/lan` → `{enabled, running, port, urls, web_urls, fingerprint, mdns, error}`.
`GET /api/nodes/lan` → `{enabled, running, port, urls, web_urls, fingerprint, mdns, bind_all, error}` (`bind_all`
true when the listener is bound to every network interface because `nodes.lan_bind_all` is on, false when it
listens on this computer only, #177).
- Domain events `node.updated` → `Node`, `node.deleted` `{node_id}`, `node.event` `{node_id, event, data}`.
- Tools (plugin `devices`, hidden unless an online device offers a capability the tools use; the desktop node counts only for
`screen.capture`): `device_list()` read; `device_take_photo(device="", question="")` read (effective risk `send` while
Expand Down
12 changes: 12 additions & 0 deletions sentient/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,18 @@ def row(name: str, ok: bool | None, detail: str = ""):
row("sqlite-vec", s.store.vec_available, "semantic memory " + ("enabled" if s.store.vec_available else "DISABLED"))
row("tools", True, f"{len(s.registry.tools())} tools from {len(s.registry.plugins())} plugins")
row("skills", True, f"{len(s.skills.list())} active, {len(s.skills.list_pending())} pending review")
try:
lan = s.nodes.lan_status()
if lan["enabled"]:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
row(
"lan listener",
not lan["bind_all"],
f"port {lan['port']}, bound to all interfaces (nodes.lan_bind_all); any host on the network can reach it, pairing required"
if lan["bind_all"]
else f"port {lan['port']}, this computer only (loopback)",
)
except Exception as exc:
row("lan listener", False, str(exc))
try:
text = ""
async for chunk in s.llm.stream("primary", [{"role": "user", "content": "Reply with the single word: ready"}]):
Expand Down
16 changes: 15 additions & 1 deletion sentient/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class below and may add fields to it. Do not edit another package's section.

from __future__ import annotations

from typing import Annotated, Literal
from typing import Annotated, Any, Literal

from pydantic import BaseModel, Field, field_validator, model_validator

Expand Down Expand Up @@ -1049,10 +1049,24 @@ class NodesConfig(BaseModel):
False, description="Accept devices on your local network (encrypted, pairing code required)."
)
lan_port: int = Field(7778, ge=1024, le=65535, description="Port devices on your network connect to.")
lan_bind_all: bool = Field(
False,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
description="Bind the device listener to every network interface (0.0.0.0) instead of this computer only "
"(loopback). Off by default: enable it only if devices cannot reach Sentient, and keep pairing on.",
)
mdns_enabled: bool = Field(
True,
description="Announce Sentient on your network (mDNS) so glasses and phones find it without typing an address.",
)

@model_validator(mode="before")
@classmethod
def _legacy_lan_bind_all(cls, data: Any) -> Any:
# Configs saved before nodes.lan_bind_all existed bound the LAN listener to every
# interface, so an existing lan_enabled opt-in keeps working until the user chooses otherwise.
if isinstance(data, dict) and "lan_bind_all" not in data and data.get("lan_enabled") is True:
data = {**data, "lan_bind_all": True}
return data
camera_requires_approval: bool = Field(
True, description="Ask before the assistant takes a photo or captures a screen with one of your devices."
)
Expand Down
2 changes: 1 addition & 1 deletion sentient/nodes/lan.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ async def run() -> None:
self.error = f"Could not listen on port {port}. Another program may be using it."
return
self.running = True
log.info("LAN device listener on https://0.0.0.0:%s (sha256 %s)", port, cert.fingerprint)
log.info("LAN device listener on https://%s:%s (sha256 %s)", host, port, cert.fingerprint)
if mdns:
await self._advertise()

Expand Down
29 changes: 25 additions & 4 deletions sentient/nodes/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def __init__(self, app):
self._uploads: dict[str, tuple[Path, str, str, float]] = {} # id -> (path, node_id, mime, created)
self._lan_lock = asyncio.Lock()
self.lan: LanListener | None = None
self.lan_host = "0.0.0.0" # tests bind loopback
self.lan_host = "0.0.0.0" if self.cfg.lan_bind_all else "127.0.0.1" # tests bind loopback explicitly
# authenticates the LAN app's internal hop into the voice socket; never leaves the process
self.lan_secret = secrets.token_urlsafe(32)
self._schema_ready = False
Expand Down Expand Up @@ -235,13 +235,26 @@ async def apply_config(self) -> None:
async with self._lan_lock:
lan = self.lan
if want_lan:
if lan is None or not lan.running or lan.port != cfg.lan_port or lan.mdns_wanted != cfg.mdns_enabled:
host = "0.0.0.0" if cfg.lan_bind_all else "127.0.0.1"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (
lan is None
or not lan.running
or lan.port != cfg.lan_port
or lan.mdns_wanted != cfg.mdns_enabled
or self.lan_host != host
):
if lan is not None:
await lan.stop()
from sentient.nodes.lan import LanListener

self.lan = LanListener(self)
self.lan_host = host
await self.lan.start(cfg.lan_port, mdns=cfg.mdns_enabled, host=self.lan_host)
if host == "0.0.0.0":
log.warning(
"nodes: LAN listener bound to ALL interfaces (nodes.lan_bind_all); "
"any host on the network can reach it and pairing is required"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
elif lan is not None:
await lan.stop()
self.lan = None
Expand All @@ -251,14 +264,22 @@ def lan_status(self) -> dict:
cfg = self.cfg
lan = self.lan
running = bool(lan and lan.running)
if running and not cfg.lan_bind_all:
# Loopback-only: never advertise network addresses devices could not (and should not) reach.
urls = [f"wss://127.0.0.1:{cfg.lan_port}/ws/node"]
web_urls = [f"https://127.0.0.1:{cfg.lan_port}/node/"]
else:
urls = lan.ws_urls() if running and lan else []
web_urls = lan.web_urls() if running and lan else []
return {
"enabled": bool(cfg.enabled and cfg.lan_enabled),
"running": running,
"port": cfg.lan_port,
"urls": lan.ws_urls() if running and lan else [],
"web_urls": lan.web_urls() if running and lan else [],
"urls": urls,
"web_urls": web_urls,
"fingerprint": lan.fingerprint if lan else None,
"mdns": bool(lan and lan.mdns),
"bind_all": bool(cfg.lan_bind_all),
"error": lan.error if lan else None,
}

Expand Down
76 changes: 76 additions & 0 deletions tests/nodes/test_lan_bind.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""SAFE-15: binding the LAN device listener to all interfaces must be an explicit opt-in."""

from __future__ import annotations

import socket


def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]


def _enable_lan(core, *, bind_all: bool) -> None:
core.config.nodes.lan_enabled = True
core.config.nodes.lan_bind_all = bind_all
core.config.nodes.lan_port = _free_port()
core.config.nodes.mdns_enabled = False


def test_lan_bind_all_defaults_off(config):
assert config.nodes.lan_bind_all is False


async def test_apply_config_binds_loopback_by_default(nodes_client):
client = nodes_client()
core = client.core
_enable_lan(core, bind_all=False)
await core.nodes.apply_config()
try:
assert core.nodes.lan and core.nodes.lan.running
assert core.nodes.lan_host == "127.0.0.1"
status = core.nodes.lan_status()
assert status["bind_all"] is False # not exposed beyond this computer
finally:
await core.nodes.lan.stop()


async def test_apply_config_requires_opt_in_to_bind_all_interfaces(nodes_client):
client = nodes_client()
core = client.core
_enable_lan(core, bind_all=True)
await core.nodes.apply_config()
try:
assert core.nodes.lan and core.nodes.lan.running
assert core.nodes.lan_host == "0.0.0.0"
status = core.nodes.lan_status()
assert status["bind_all"] is True # surfaces the exposure warning in doctor and Settings
finally:
await core.nodes.lan.stop()


def test_legacy_lan_config_counts_as_bind_all_opt_in():
from sentient.config.schema import NodesConfig

legacy = NodesConfig.model_validate({"lan_enabled": True})
assert legacy.lan_bind_all is True # saved before the flag existed: listener bound 0.0.0.0, keep working

explicit = NodesConfig.model_validate({"lan_enabled": True, "lan_bind_all": False})
assert explicit.lan_bind_all is False # an explicit choice is never overridden

fresh = NodesConfig.model_validate({})
assert fresh.lan_bind_all is False # new users stay loopback-only


async def test_loopback_only_never_advertises_network_addresses(nodes_client):
client = nodes_client()
core = client.core
_enable_lan(core, bind_all=False)
await core.nodes.apply_config()
try:
status = core.nodes.lan_status()
assert status["urls"] == [f"wss://127.0.0.1:{core.config.nodes.lan_port}/ws/node"]
assert status["web_urls"] == [f"https://127.0.0.1:{core.config.nodes.lan_port}/node/"]
finally:
await core.nodes.lan.stop()
Loading