feat(writes): enable write actions by default (opt-out)
CI / lint (pull_request) Successful in 51s
CI / test (pull_request) Successful in 58s
CI / publish (pull_request) Has been skipped
CI / lint (push) Successful in 50s
CI / test (push) Successful in 57s
CI / publish (push) Successful in 51s

Invert the global write toggle so writes are ENABLED by default and the
user opts OUT, while keeping all other safety intact (per-call confirm=true
dry-run preview and the on-disk audit log are unchanged).

- writes.py: use a `writes_disabled` flag file instead of `writes_enabled`;
  `writes_enabled()` returns True unless the flag exists (env var still
  honored either way); `set_writes(False)` creates the flag, `set_writes(True)`
  removes it. Update user-facing notes.
- tests: default is now ENABLED; adjust toggle/disabled tests (set disabled
  explicitly via set_writes(False) or FAMILIAR_ENABLE_WRITES=0).
- README.md / CLAUDE.md: document writes are on by default, opt out via
  `familiar writes disable`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
LatteandClaude Opus 4.8 committed 2026-06-27 20:39:25 +02:00
1 parent 0f7a861fc4
commit 0dfd6d2350
6 files changed
+36 -23

No files matched your search

+2 -2
View File
@@ -19,7 +19,7 @@ uv run ruff check . # lint (run both before committing; CI enforces th
uv run familiar # run the MCP server over stdio (how Claude launches it) uv run familiar # run the MCP server over stdio (how Claude launches it)
uv run familiar login # browser EVE SSO login (adds a character; run once per character) uv run familiar login # browser EVE SSO login (adds a character; run once per character)
uv run familiar status # list logged-in characters uv run familiar status # list logged-in characters
uv run familiar writes enable # allow write actions (default: disabled) uv run familiar writes disable # block write actions (default: enabled)
``` ```
There is no separate build step; `uv build` produces the wheel (CI does this on merge). There is no separate build step; `uv build` produces the wheel (CI does this on merge).
@@ -34,7 +34,7 @@ There is no separate build step; `uv build` produces the wheel (CI does this on
**Per-character / per-corp pattern.** [charutil.py](src/familiar/charutil.py) is the backbone of every authenticated tool. `run_per_character(character, fetch)` runs a fetcher for one named character or **all** logged-in ones (when `character` is None), returning a uniform `{"characters": [...]}`. `run_per_corp` additionally resolves each character's `corporation_id` (corp endpoints need the right corp role or ESI 403s). `one_token` picks a single token for thing-scoped lookups (e.g. a structure). `summarize(items, limit)` gives `{count, sample}`. account.py and all read modules build on these. **Per-character / per-corp pattern.** [charutil.py](src/familiar/charutil.py) is the backbone of every authenticated tool. `run_per_character(character, fetch)` runs a fetcher for one named character or **all** logged-in ones (when `character` is None), returning a uniform `{"characters": [...]}`. `run_per_corp` additionally resolves each character's `corporation_id` (corp endpoints need the right corp role or ESI 403s). `one_token` picks a single token for thing-scoped lookups (e.g. a structure). `summarize(items, limit)` gives `{count, sample}`. account.py and all read modules build on these.
**Write safety — the choke point.** [writes.py](src/familiar/writes.py) `guard(action, params, confirm, execute)` is the single gate every mutating tool calls. `confirm=False` → dry-run preview (nothing happens); `confirm=True` but writes disabled → `WriteDisabled`; `confirm=True` + enabled → execute + append to an audit log. Writes are **off by default** (toggle via `familiar writes enable` / `FAMILIAR_ENABLE_WRITES`). Write tools take a *required* `character` and a `confirm` flag; reads take an *optional* `character`. When adding a write tool, route it through `writes.guard` — never call a mutating ESI endpoint directly. **Write safety — the choke point.** [writes.py](src/familiar/writes.py) `guard(action, params, confirm, execute)` is the single gate every mutating tool calls. `confirm=False` → dry-run preview (nothing happens); `confirm=True` but writes disabled → `WriteDisabled`; `confirm=True` + enabled → execute + append to an audit log. Writes are **on by default** (opt out via `familiar writes disable`; toggle via `familiar writes enable`/`disable` or `FAMILIAR_ENABLE_WRITES`). Write tools take a *required* `character` and a `confirm` flag; reads take an *optional* `character`. When adding a write tool, route it through `writes.guard` — never call a mutating ESI endpoint directly.
## Testing conventions ## Testing conventions
+6 -4
View File
@@ -8,7 +8,7 @@ Ask your familiar where your assets are, what your skill queue looks like, what
## Principles ## Principles
- **Safe by default.** Reading is free; every *write* — sending mail, setting waypoints, editing contacts/fittings/PI, fleet commands — is off until you turn it on, previewed before it runs, and recorded in an audit log. The AI can see everything and changes nothing unless you let it. - **Safe by default.** Reading is free; every *write* — sending mail, setting waypoints, editing contacts/fittings/PI, fleet commands — is previewed before it runs (nothing happens without `confirm=true`) and recorded in an audit log. Writes are on by default but can be turned off entirely with `familiar writes disable`. The AI can see everything and changes nothing without your explicit confirm.
- **Local-first.** Runs on your own machine. Your refresh token and account data never leave your control. - **Local-first.** Runs on your own machine. Your refresh token and account data never leave your control.
- **Everything, eventually.** The goal is full coverage: the ESI scopes you grant (character *and* corp, read and write), plus the complete static universe. Built in phases so it actually ships. - **Everything, eventually.** The goal is full coverage: the ESI scopes you grant (character *and* corp, read and write), plus the complete static universe. Built in phases so it actually ships.
@@ -49,12 +49,14 @@ Claude Desktop (`claude_desktop_config.json`):
Then ask in natural language — "what's Tritanium worth in Jita?", "how much ISK across all my characters?", "where are my characters?". Then ask in natural language — "what's Tritanium worth in Jita?", "how much ISK across all my characters?", "where are my characters?".
### Enabling writes ### Write actions
Write actions (send mail, set waypoints, edit contacts/fittings, fleet commands) are **off by default**. Every write tool also returns a dry-run preview unless called with `confirm=true`, and each executed write is recorded in an audit log. Write actions (send mail, set waypoints, edit contacts/fittings, fleet commands) are **on by default**. Even so, every write tool returns a dry-run preview unless called with `confirm=true` per call, and each executed write is recorded in an audit log — so nothing changes by accident.
To opt out and block all writes globally:
```bash ```bash
uv run familiar writes enable # allow writes (uv run familiar writes disable to revoke) uv run familiar writes disable # turn writes off (uv run familiar writes enable to re-allow)
``` ```
## Roadmap ## Roadmap
+15 -10
View File
@@ -3,7 +3,7 @@
familiar is safe by default: a write only happens when **all** of these hold: familiar is safe by default: a write only happens when **all** of these hold:
1. the caller passes ``confirm=True`` (otherwise it gets a dry-run preview), and 1. the caller passes ``confirm=True`` (otherwise it gets a dry-run preview), and
2. writes are enabled globally (`familiar writes enable`, default off). 2. writes are enabled globally (on by default; opt out via `familiar writes disable`).
Every executed write is appended to an audit log on disk. This module is the Every executed write is appended to an audit log on disk. This module is the
single choke point every write tool must go through. single choke point every write tool must go through.
@@ -25,24 +25,29 @@ class WriteDisabled(RuntimeError):
def _flag_file(): def _flag_file():
return config.data_dir() / "writes_enabled" return config.data_dir() / "writes_disabled"
def writes_enabled() -> bool: def writes_enabled() -> bool:
"""True if writes are allowed (env override or the on-disk toggle).""" """True if writes are allowed (env override or the on-disk toggle).
Writes are **enabled by default**; the user opts out by creating the
disable-flag file (`familiar writes disable`) or setting the env var.
"""
env = os.environ.get("FAMILIAR_ENABLE_WRITES") env = os.environ.get("FAMILIAR_ENABLE_WRITES")
if env is not None: if env is not None:
return env.strip().lower() in {"1", "true", "yes", "on"} return env.strip().lower() in {"1", "true", "yes", "on"}
return _flag_file().exists() return not _flag_file().exists()
def set_writes(enabled: bool) -> None: def set_writes(enabled: bool) -> None:
"""Turn the on-disk write toggle on or off.""" """Turn the on-disk write toggle on or off (writes are on by default)."""
flag = _flag_file() flag = _flag_file()
if enabled: if enabled:
flag.write_text("enabled\n", encoding="utf-8") if flag.exists():
elif flag.exists(): flag.unlink()
flag.unlink() else:
flag.write_text("disabled\n", encoding="utf-8")
def audit_log_path(): def audit_log_path():
@@ -77,7 +82,7 @@ async def guard(
if not writes_enabled(): if not writes_enabled():
raise WriteDisabled( raise WriteDisabled(
f"Writes are disabled, so '{action}' was not performed. " f"Writes are disabled, so '{action}' was not performed. "
"Run `familiar writes enable` to allow write actions." "Run `familiar writes enable` to re-allow write actions."
) )
result = await execute() result = await execute()
_audit({"action": action, "params": params}) _audit({"action": action, "params": params})
@@ -92,5 +97,5 @@ def register(mcp) -> None:
"""Whether familiar is currently allowed to perform write actions.""" """Whether familiar is currently allowed to perform write actions."""
return { return {
"writes_enabled": writes_enabled(), "writes_enabled": writes_enabled(),
"note": "Enable with `familiar writes enable`; write tools also need confirm=true.", "note": "On by default; disable with `familiar writes disable`. Write tools also need confirm=true.",
} }
+1 -1
View File
@@ -46,7 +46,7 @@ def test_send_mail_dry_run_does_not_post(mock_esi, fake_auth):
def test_send_mail_disabled_raises(mock_esi, fake_auth, tmp_path, monkeypatch): def test_send_mail_disabled_raises(mock_esi, fake_auth, tmp_path, monkeypatch):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path)) monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
monkeypatch.delenv("FAMILIAR_ENABLE_WRITES", raising=False) monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "0")
mock_esi(lambda r: httpx.Response(201, json=1)) mock_esi(lambda r: httpx.Response(201, json=1))
with pytest.raises(writes.WriteDisabled): with pytest.raises(writes.WriteDisabled):
asyncio.run(comms.send_mail(99, [{"recipient_id": 1}], "s", "b", confirm=True)) asyncio.run(comms.send_mail(99, [{"recipient_id": 1}], "s", "b", confirm=True))
+1 -1
View File
@@ -70,7 +70,7 @@ def test_get_fittings(mock_esi, fake_auth):
def test_create_fitting_disabled_raises(mock_esi, fake_auth, tmp_path, monkeypatch): def test_create_fitting_disabled_raises(mock_esi, fake_auth, tmp_path, monkeypatch):
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path)) monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
monkeypatch.delenv("FAMILIAR_ENABLE_WRITES", raising=False) monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "0")
mock_esi(lambda r: httpx.Response(201, json={"fitting_id": 5})) mock_esi(lambda r: httpx.Response(201, json={"fitting_id": 5}))
with pytest.raises(writes.WriteDisabled): with pytest.raises(writes.WriteDisabled):
asyncio.run(fittings.create_fitting(99, {"name": "x"}, confirm=True)) asyncio.run(fittings.create_fitting(99, {"name": "x"}, confirm=True))
+11 -5
View File
@@ -19,15 +19,15 @@ async def _exec():
return {"ok": True} return {"ok": True}
def test_disabled_by_default(tmp_data): def test_enabled_by_default(tmp_data):
assert writes.writes_enabled() is False assert writes.writes_enabled() is True
def test_toggle_on_disk(tmp_data): def test_toggle_on_disk(tmp_data):
writes.set_writes(True)
assert writes.writes_enabled() is True
writes.set_writes(False) writes.set_writes(False)
assert writes.writes_enabled() is False assert writes.writes_enabled() is False
writes.set_writes(True)
assert writes.writes_enabled() is True
def test_env_override(tmp_data, monkeypatch): def test_env_override(tmp_data, monkeypatch):
@@ -50,12 +50,18 @@ def test_dry_run_does_not_execute(tmp_data):
def test_confirm_but_disabled_raises(tmp_data): def test_confirm_but_disabled_raises(tmp_data):
writes.set_writes(False)
with pytest.raises(writes.WriteDisabled):
asyncio.run(writes.guard("send_mail", {}, confirm=True, execute=_exec))
def test_confirm_but_disabled_via_env_raises(tmp_data, monkeypatch):
monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "0")
with pytest.raises(writes.WriteDisabled): with pytest.raises(writes.WriteDisabled):
asyncio.run(writes.guard("send_mail", {}, confirm=True, execute=_exec)) asyncio.run(writes.guard("send_mail", {}, confirm=True, execute=_exec))
def test_confirm_enabled_executes_and_audits(tmp_data): def test_confirm_enabled_executes_and_audits(tmp_data):
writes.set_writes(True)
result = asyncio.run(writes.guard("send_mail", {"subject": "hi"}, confirm=True, execute=_exec)) result = asyncio.run(writes.guard("send_mail", {"subject": "hi"}, confirm=True, execute=_exec))
assert result["executed"] is True assert result["executed"] is True
assert result["result"] == {"ok": True} assert result["result"] == {"ok": True}