# Security

The Hub runs scripts that a model wrote, with your rights, inside your applications. This page says what the Hub bounds, which risks remain and what leaves your machine, and gives the measurements replayed for it. It is for anyone deciding whether to connect a model to their applications.

The measurements ran on Debian 13 with the Hub 0.1.0 built from its sources; those on a running Hub used a throwaway home directory with its own token.

> **Limit**
>
> The Hub is not a sandbox. A script runs with the rights of your user, inside an application whose Python can do whatever you can. The script guard stops a clumsy script, not a determined one. LibreOffice's UNO port has no authentication, and another process of your user can read the token file. A script that loops keeps its application busy until you close it, except in the `cli` mode.


## What leaves the machine

Everything the Hub returns goes to the language model, and so to whoever runs it.

| What | Leaves the machine |
|---|---|
| The script the model wrote | Yes: the model is its author |
| `stdout`, `stderr` and `result` of a script | Yes: they can hold the content of a document |
| The error trace | Yes: it holds paths of the machine |
| `state` | Yes: it holds content taken from the open documents |
| The path of a capture | Yes: it is part of the answer |
| The image of a capture | Only if the model reads the file |
| The audit journal, the token and the configuration | Never |

A user who opens a confidential document in a connected application knows that its content can be read by the model. In the replay, `get_state` returned the label `Notes` of a layer that a script had created, with `content_origin` set to `document`; that label is read the way the content of a document is.

The Hub connects only to bridges on `127.0.0.1`. Run under `strace -f -e trace=connect,bind,listen` through a session that included a script that outlasted its delay, the Hub made one `connect` over IP in total: to `127.0.0.1`, on the port of the Blender bridge. `cargo test --locked --test invariants` reads the source for any other.

## Who can reach a bridge

A bridge listens on `127.0.0.1` only, accepts one connection at a time and answers a client that opens with the token. `ss -ltn` showed one listening socket for the Blender bridge, on the port set for the run: `127.0.0.1:9313`. Four probes were sent to it before the Hub connected:

- A `hello` with a wrong token: 0 bytes back, and the connection closed at once.
- An HTTP request: 0 bytes back, and the connection closed at once.
- Nothing sent: 0 bytes back, and the connection closed after 5.0 s.
- A `hello` with the right token: 99 bytes back, the `hello` answer, which names the application and the versions and does not hold the token. The connection stayed open.

LibreOffice's own UNO port has no authentication. With `soffice` listening on `127.0.0.1:2102` and no token presented, a Python client using `python3-uno` read the LibreOffice version, `25.2.3.2`, opened a Calc document and wrote a cell into it. That path bypasses the token, the script guard and the audit journal.

## What the Hub bounds

The limits are keys of the `[hub]` section of `config.toml`, in the file `init` writes; the values below are the defaults it lists.

- **Script size**, `max_script_bytes`, 262,144 bytes: a script of 300,009 bytes was refused with `script is 300009 bytes; the limit is 262144 bytes`.
- **Output per stream**, `max_output_bytes`, 1,048,576 bytes: a script printing 2,000,000 characters returned a `stdout` of 1,048,576 bytes and `"truncated": true`.
- **Time**, `default_timeout_s`, 30 s: in the `cli` mode, `timeout` after 30.0 s, no child process left, and the next call `ok` in 31 ms. In the `tcp` mode, `timeout` after 31.2 s (32.0 s in the second run), then `busy` for the next call.
- **One script at a time per application**: the `busy` above. In the `tcp` mode the application was not stopped; it ran its 40-second script to the end, and the Hub answered `ok` again 40.3 s after the start (40.1 s in the second run).

Only the `cli` mode kills a script that runs past its time. In the `tcp` and `uno` modes the Hub cannot interrupt a script inside the application: it answers `timeout`, refuses new scripts with `busy`, and waits.

## The script guard

The guard reads the text of a script before it is sent. A script that called `os.remove` was refused before reaching Inkscape, with the category, the construct and the line: `G-1 (file deletion) at line 3: os.remove`. When a path cannot be checked from the text, the guard lets the script through and adds a warning to `stderr`, for example `path not statically verifiable (open())`.

A script that wrote a second file and ran it with `runpy` deleted a file it had created, and the Hub answered `ok`. That is the limit the repository's README states: the guard does not see `runpy`, `pickle`, `http.server`, the save functions of the applications, or a path passed to a function as a parameter. The operator lifts the guard with `allow_dangerous_scripts = true` in the `[security]` section; the two tools take no such parameter, and the journal then records `guard: bypassed`.

On the guard's false positives, `cargo test --locked --test guard realistic -- --nocapture` printed `false positives: 0 of 21 realistic scripts (0.0%)`.

## Files, modes and refusals to start

After `init`, `config.toml` and `token` were both in mode 600. A capture asked for by `get_state` was a file in mode 600 in a directory in mode 700, under `XDG_RUNTIME_DIR`; it was still there after the Hub exited with code 0.

The Hub refused to start, with exit code 1 and one line of explanation, in five cases:

| Case | Message, without the path |
|---|---|
| the token file in mode 0640 | `has mode 0640; expected 0600 (chmod 600)` |
| no token file | `does not exist` |
| `config.toml` in mode 0644 | `configuration file refused: … has mode 0644; expected 0600 (chmod 600)` |
| the Hub's runtime directory in mode 0755 | `has mode 0755; expected 0700 (chmod 700)` |
| `XDG_RUNTIME_DIR` unset | `XDG_RUNTIME_DIR is not set; refusing to write volatile files elsewhere` |

The token value appeared in none of these messages. `libremodular-launch libreoffice` refused a bridge file in mode 0664 before starting anything, with exit code 2: `has mode 0664 and can be changed by the group or by others (chmod go-w)`.

## The audit journal

Every call writes one line to `audit.jsonl` in the state directory, in mode 600, `get_state` included. Four calls (a script that ran, one the guard refused, one that raised an error, one for a disabled application) wrote four lines. A line holds the date, the tool, the target, the status, the duration, the size and the SHA-256 of the script, the guard's result and the size of the output:

```json
{
  "ts": "2026-10-21T08:56:27.564Z",
  "tool": "execute_script",
  "target_app": "inkscape",
  "status": "refused",
  "duration_ms": 0,
  "script_bytes": 60,
  "script_sha256": "3696b8a4f24f23da34ba239bbb401c56cd78fb53c2bef48ab2f7d668fd1fdbf8",
  "guard": [
    "G-1"
  ],
  "output_bytes": 524
}
```

The file holds each line on one line; it is broken here to be read.

A marker string placed in the scripts was not found in the journal, nor was the token. The script text is written only if the operator sets `audit_script_text = true`.

## The tests of the repository

These commands are those of the repository's CI. They ran outside the repository (`CARGO_TARGET_DIR`).

| Command | Result |
|---|---|
| `cargo fmt --check` | exit code 0 |
| `cargo clippy --all-targets --locked -- -D warnings` | exit code 0 |
| `cargo test --locked` | 328 passed, 0 failed, 3 ignored: two need a real LibreOffice, one needs a user that is not root |
| `cargo test --locked --test invariants` | 36 passed |
| `python3 scripts/build_bridges.py --check` | exit code 0 |
| `python3 -m unittest discover -s tests/bridges` | 189 tests, OK, 6 skipped: two need root, four are for Windows only |

To report a vulnerability, see [About](https://libremodular.davalan.fr/about/index.md#report-a-vulnerability).

