From f3c96e25ede1da6b5a9e00269a0b7d6d60cbf5cd Mon Sep 17 00:00:00 2001 From: Latte <1+latte@noreply.hiddenden.cafe> Date: Mon, 29 Jun 2026 13:48:58 +0000 Subject: [PATCH] Add Write Safety wiki page --- Write-Safety.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 Write-Safety.md diff --git a/Write-Safety.md b/Write-Safety.md new file mode 100644 index 0000000..1ed246a --- /dev/null +++ b/Write-Safety.md @@ -0,0 +1,95 @@ +# Write Safety + +familiar began "read-only by design" and is now **read + write** — but writing is gated behind a single, deliberately strict safety layer. This page explains exactly when a write happens and what stops it. + +## The three conditions + +A write only happens when **all** of these hold: + +1. The tool is called with **`confirm=true`** (otherwise it returns a dry-run preview and changes nothing). +2. Writes are **enabled globally** (on by default; opt out with `familiar writes disable`). +3. The action runs through **`writes.guard`** — the single choke point every mutating tool must use. + +Every executed write is appended to an **audit log** on disk. + +## The gate: `writes.guard` + +[`writes.py`](https://git.hiddenden.cafe/Hiddenden/familiar/src/branch/main/src/familiar/writes.py) exposes one function that every write tool calls: + +```python +async def guard(action, params, confirm, execute): + # confirm=False -> dry-run preview, execute nothing + # confirm=True, writes off -> raise WriteDisabled + # confirm=True, writes on -> execute(), append to audit log, return result +``` + +### `confirm=false` → dry-run preview + +The default. Nothing is changed. The tool returns: + +```json +{ + "dry_run": true, + "action": "set_waypoint", + "params": { "...": "..." }, + "note": "Nothing was changed. Pass confirm=true to execute (writes must be enabled)." +} +``` + +This is how the AI can *show you what it would do* before you commit. You confirm by asking it to run the same action with `confirm=true`. + +### `confirm=true` but writes disabled → `WriteDisabled` + +If you've globally turned writes off, a confirmed write still does nothing and raises `WriteDisabled` telling you to run `familiar writes enable` to re-allow it. + +### `confirm=true` and writes enabled → execute + audit + +The action runs, and a line is appended to the audit log (see below). + +## The global toggle + +Writes are **on by default**. The toggle is controlled three ways, in precedence order: + +1. **Environment variable** `FAMILIAR_ENABLE_WRITES` — if set, it wins (`1/true/yes/on` = enabled). +2. **On-disk flag file** `writes_disabled` in the data dir — created/removed by the CLI. +3. **Default** — enabled (no flag file present). + +CLI: + +```bash +uv run familiar writes # show current state +uv run familiar writes disable # create the flag → block all writes +uv run familiar writes enable # remove the flag → re-allow writes +``` + +The `write_status` MCP tool reports the current state to the AI without touching the toggle. + +## The audit log + +Each executed write appends one JSON line to `audit.log` in the data dir: + +```json +{"ts": 1719600000.0, "action": "send_mail", "params": { "...": "..." }} +``` + +Dry-runs and blocked (disabled) writes are **not** logged — only real, executed writes are. The log lives alongside the encrypted token store (see **[Configuration](Configuration)** for the path). + +## Which tools are writes? + +Every tool marked **WRITE** in the **[Tool Reference](Tool-Reference)**: + +- **Comms:** `send_mail`, `set_mail_read`, `respond_calendar_event` +- **Contacts:** `add_contacts`, `remove_contacts` +- **Fittings:** `create_fitting`, `delete_fitting` +- **Fleet:** `invite_to_fleet`, `update_fleet` +- **UI:** `set_waypoint`, `open_window` + +These are powered by the OAuth **write scopes** granted at login (see **[Authentication & Scopes](Authentication-and-Scopes)**). + +## For contributors + +When adding a write tool: + +- Give it a **required** `character` and a `confirm: bool = False` parameter. +- Route the mutation through **`writes.guard`** — never call a mutating ESI endpoint directly. +- Pass a clear `action` string and the user-facing `params` so the dry-run preview and audit line are meaningful.