# tines host hardening — before a robot leaves the lab Date: 2026-10-19 Status: draft v1. Not yet reviewed. Fixes: findings F10–F12 in `THREAT_MODEL.md` (assessed at `3b1a644`) Depends on: `2026-10-19-tines-fleet-fixes.md`, which adds the poll loop, the trim/flush hooks and the log format this spec builds on ## Intent The hosts that run tines on a robot (`tines`, `tines-ros2`, and the `tines-bt` and `tines-store` crates under them) currently trust three things they shouldn't: - that storage never fills, - that every local process is friendly, - that every participant on the ROS2 domain is friendly. This spec bounds every resource a peer, a local process or a ROS2 publisher can grow, and limits who can act as the robot. It's the bar a robot must meet before it operates anywhere other than a trusted lab. ## Why separately from 1.0 Everything here is in host crates, which stay 0.x with their own versions (1.0 spec, D4). None of it changes the wire or core's API. Tying it to the core 1.0 release would make one wait on the other for no benefit. The bar is deployment readiness, not a version number. ## Goals 1. **Nothing grows without bound** (F10): store size, log size, channels, and per-request allocations. 2. **Only authorised local users can act as the robot** through the service's IPC (F11), and no client can block the others. 3. **The ROS2 bridge doesn't feed on itself** (F12): it can be restricted to a trusted DDS scope, can run read-only, and rate-limits what it accepts. 4. **Hosts run with least privilege**, under a provided systemd unit. ## Non-goals - Authenticating peers or signing observations (1.1). These limits make floods *bounded*; they don't make them *impossible*. - Confidentiality of the radio link (threat model F15). - Core changes. Per-peer behaviour in the exchange (eviction, summary rate limits) is in the 1.0 spec §10. ## Constraints - No changes to tines-core's API or wire format. - Defaults must be safe without configuration: a robot that gets an untuned config must still be bounded. - Every limit is configurable, has a documented default, and is reported (counter or log line) when it bites. A silent limit looks like a bug. ## The how ### 1. F10 — bounded growth 1. **Default trim policy.** Both hosts call `trim` on the poll loop's schedule (fleet fixes §2), every `trim_interval_ms` (default 10 s), with a config `TrimPolicy` whose defaults are always on: - `max_count`: default 50,000 observations (about 50 MB worst case at the 1 KiB payload cap; size it per robot). - `max_age_ms`: default 7 days. - `keep_per_topic`: unset by default. The fleet spec's sizing guidance applies: custody traffic must outlive its lifetime. The host docs say how to size `max_count` for that. 2. **Store full stops being silent.** `LogStore` gains an optional hard cap (`max_records`, defaulting to 2 × `max_count`). Above it, `insert` returns `StoreError::Full`. With the fleet fix for F1, a full store no longer acks, so custody stays with the sender. That's the safe direction, and it's the point of having a cap. 3. **Log compaction.** When dead records (trimmed or superseded) exceed `compact_ratio` of the file (default 0.5), and at most once per `compact_interval_ms`, `LogStore` writes the live records and current release flags to a new file. It fsyncs the file, renames it over the old one, and fsyncs the directory. A crash during compaction leaves the old log intact. Releases written as records (fleet fixes §4) are carried over. 4. **Bounded channels.** - tines-bt inbound: a bounded `mpsc` of `BT_INBOUND_CAP` frames (default 256). When full, the oldest is dropped and counted. A frame is droppable; the protocol tolerates loss. - tines-bt outbound: a bounded channel. When full, `send` returns `SendError::Busy`, which core already retries via `on_send_error`. - tines-ros2 inbound: bounded (default 256), dropping the oldest with a counter. 5. **Per-request allocation.** The IPC frame cap drops from 1 MiB to 16 KiB. The largest valid request is a hex-encoded 64-byte topic plus a 1 KiB payload in JSON, about 2.3 KiB. 6. **Load.** `LogStore::open` reads the log in a stream instead of `fs::read` of the whole file, so memory use at open is bounded by the index, not the file size. ### 2. F11 — local IPC 1. **Socket location and mode.** - Default path `$RUNTIME_DIRECTORY/tines.sock`, provided by systemd's `RuntimeDirectory=tines` with mode `0750`. - The socket is created `0660`, owned by the service user and a configurable `ipc_group` (default `tines`). - Only members of that group can connect. 2. **Peer credentials.** On accept, read `SO_PEERCRED`. Allow if the uid is the service uid, or the gid or any supplementary group is `ipc_group`, or the uid is in the config `ipc_allow_uids`. Anything else is closed with a logged refusal. This is defence in depth on top of the file mode. 3. **Read-only clients.** Config `ipc_readonly_uids` and `ipc_readonly_gid` get `Belief`, `Status` and `Events` only. `Observe` requires the full grant. Most consumers only read, and shouldn't be able to speak as the robot. 4. **Don't unlink arbitrary paths.** Remove the configured path only if it's a socket (`symlink_metadata` → file type socket). Otherwise fail with an error. 5. **Concurrency and timeouts.** - Serve each client on its own thread, up to `ipc_max_clients` (default 8); beyond that, accept and close. - Each connection has read and write timeouts (`ipc_timeout_ms`, default 5 s). - An idle client is closed. No client can block another. 6. **Framing errors close the connection.** An oversized length is rejected and the connection is closed, so the stream can't be left desynchronised. Today it returns an error and keeps reading. 7. **`accept` errors** (for example `EMFILE`) are logged and retried with backoff; they no longer end the daemon. 8. **Events.** `Events` stays a destructive drain (one consumer), and the docs say so. Fan-out to several consumers is a later feature if it's needed. ### 3. F12 — the ROS2 bridge 1. **No self-echo.** The bridge must not re-observe its own publications. - Prefer the middleware option that ignores local publications, if rclrs exposes it. - Otherwise, filter in the callback by publisher GID if available, or else drop an inbound message whose payload equals the last payload the bridge published on that topic within `echo_window_ms` (default 1 s). - **Test:** publish once, poll several times; the store gains exactly one observation. 2. **Read-only mode.** Config `bridge_inbound` (default `false`). When false, the bridge creates publishers only. ROS2 consumers can read tines state, but nothing on the domain can make the robot say anything. Enabling inbound is an explicit decision per deployment. 3. **Rate limits.** When inbound is enabled, a per-topic token bucket applies (`inbound_rate_per_s`, default 2; burst 4). Excess messages are dropped and counted. A bridged topic should carry facts, not telemetry streams. 4. **DDS scope.** - The docs and the sample unit set a dedicated `ROS_DOMAIN_ID` and `ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST` unless the deployment needs the ROS graph off-box. - For off-box graphs, the docs describe enabling SROS2: an enclave for the `tines` node, with governance that allows only listed participants to publish on `/tines/**`. - This spec documents SROS2 setup; it doesn't automate it. 5. **Receipts topic.** `/tines/receipts` reveals peer NodeIds and observation ids. It stays on, but can be disabled (`publish_receipts`, default `true`) for deployments that bridge off-box without SROS2. ### 4. Least privilege and the systemd unit Provide `packaging/systemd/tines.service` (standalone) and a ROS2 launch note. The unit: ```ini [Service] User=tines Group=tines SupplementaryGroups=bluetooth StateDirectory=tines # log lives here, 0700 RuntimeDirectory=tines # socket lives here RuntimeDirectoryMode=0750 Restart=on-failure NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes RestrictAddressFamilies=AF_UNIX AF_BLUETOOTH CapabilityBoundingSet= ``` - The service never runs as root. - BlueZ access goes through D-Bus policy for the `bluetooth` group. The docs list the bluetoothd configuration the target images need. - A panic exits non-zero, and `Restart=on-failure` brings the service back (fleet fixes §2). ## Acceptance - Each limit has a test that drives it past its cap: the counter or log fires, and memory or disk stays bounded: - trim, - the store cap, - compaction (including a crash mid-compaction), - both BT channels, - the ROS2 inbound channel and rate limit, - the IPC frame cap. - **IPC tests:** - wrong uid refused; - read-only uid can't `Observe`; - an idle client doesn't block a second client; - an oversized frame closes the connection; - a non-socket path isn't unlinked. - **ROS2 tests:** the self-echo test, and that read-only mode creates no subscriptions. These run in the existing ROS2 Docker job (`Dockerfile.ros2`). - The systemd unit passes `systemd-analyze verify` and runs the service on the Pi image. - `THREAT_MODEL.md` marks F10–F12 mitigated and updates §5's "Bounded memory" row. ## Left to the builder - Exact defaults where marked; they get tuned once real robots are measured. - Whether rclrs exposes local-publication filtering (and so which echo filter is used). - Compaction triggers beyond the ratio, for example on open. ## Sequencing Follows the fleet fixes. Within this spec, the order is: 1. channels and the IPC cap (small, independent); 2. trim defaults and the store cap; 3. compaction; 4. IPC permissions, credentials and concurrency; 5. ROS2; 6. the systemd unit.