Add Write Safety wiki page

Latte committed 2026-06-29 13:48:58 +00:00
1 parent c64158da94
commit f3c96e25ed
1 file changed
+95
+95
@@ -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.