Phase 4: write-action safety & guardrails (#22)
Add writes.py: the single gate every write tool must pass. A write happens only when confirm=true AND writes are enabled (off by default); otherwise a dry-run preview is returned. Every executed write is appended to an audit log. New `familiar writes [enable|disable|status]` CLI and a read-only write_status MCP tool. 81 tests, coverage 92%. Closes #22. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
4 files changed
+187
-7
No files matched your search
@@ -0,0 +1,65 @@
|
||||
"""Write-safety guardrails: toggle, dry-run, audit log."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from familiar import writes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_data(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("FAMILIAR_ENABLE_WRITES", raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
async def _exec():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def test_disabled_by_default(tmp_data):
|
||||
assert writes.writes_enabled() is False
|
||||
|
||||
|
||||
def test_toggle_on_disk(tmp_data):
|
||||
writes.set_writes(True)
|
||||
assert writes.writes_enabled() is True
|
||||
writes.set_writes(False)
|
||||
assert writes.writes_enabled() is False
|
||||
|
||||
|
||||
def test_env_override(tmp_data, monkeypatch):
|
||||
monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "true")
|
||||
assert writes.writes_enabled() is True
|
||||
monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "0")
|
||||
assert writes.writes_enabled() is False
|
||||
|
||||
|
||||
def test_dry_run_does_not_execute(tmp_data):
|
||||
ran = []
|
||||
|
||||
async def execute():
|
||||
ran.append(1)
|
||||
return "done"
|
||||
|
||||
result = asyncio.run(writes.guard("send_mail", {"to": 1}, confirm=False, execute=execute))
|
||||
assert result["dry_run"] is True
|
||||
assert not ran # never executed
|
||||
|
||||
|
||||
def test_confirm_but_disabled_raises(tmp_data):
|
||||
with pytest.raises(writes.WriteDisabled):
|
||||
asyncio.run(writes.guard("send_mail", {}, confirm=True, execute=_exec))
|
||||
|
||||
|
||||
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))
|
||||
assert result["executed"] is True
|
||||
assert result["result"] == {"ok": True}
|
||||
lines = writes.audit_log_path().read_text(encoding="utf-8").strip().splitlines()
|
||||
entry = json.loads(lines[-1])
|
||||
assert entry["action"] == "send_mail"
|
||||
assert "ts" in entry
|
||||
Reference in new issue
Block a user