feat(writes): enable write actions by default (opt-out)
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:
6 files changed
+36
-23
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in new issue
Block a user