feat: self-alteration tools (edit_playbook, edit_settings, edit_source)

Gives the assistant three new ACTION tools to change its own playbooks,
runtime settings, and (source checkout only) its own source code, all
reusing the existing run_snippet/remember approval framework — but with
a hardcoded floor (self_edit.ALWAYS_ASK_TOOLS) so these three always pause
for per-call human approval regardless of the global action_tool_policy
setting. Flipping that policy for an unrelated tool must never silently
also unlock unattended self-modification.

synapse/self_edit.py is the new module doing the actual work, documented
in the same explicit "here's what is and isn't a security boundary" style
as code_run.py:
  - edit_source is confined to settings.project_root via the same
    realpath + Path.parents boundary check that just closed a sibling-
    directory bypass in /icons/image, plus a denylist of dangerous
    subtrees (.git, the venv, node_modules, build output, runtime state).
    Gated on settings.source_checkout — refuses cleanly in a wheel
    install, where there's no live repo to edit or commit into.
  - The model sends full file content, never a diff; the server computes
    the diff itself via difflib against what's actually on disk, so a
    human reviews ground truth, not a description the model wrote.
  - Every applied source edit best-effort commits to git as an audit
    trail — independent of, not a substitute for, the approval gate.
  - edit_playbook merges instead of replacing (main.py's prior
    _persist_playbook did a raw replace, which was only safe because the
    frontend form always sent a complete object — unsafe for a tool a
    model calls with a partial argument set, so this also fixes that
    latent bug). Becoming the active system prompt requires an explicit
    make_active flag, never a side effect of an ordinary edit.
  - edit_settings reuses the existing _SETTINGS_DEFAULTS allowlist.

The approval UI (Chatbot.jsx) previously rendered a tool call's arguments
as Object.values(args).join(", ") in a single-line badge — unusable for
reviewing a diff. It now renders a real, server-computed preview (diff
for source, before/after for playbook/settings) via a new shared
diff-view.js helper, with a loud banner when a change would become the
active system prompt or touch action_tool_policy/system_prompt. A new
nexus-edit fence (self-edit-langs.js + Markdown.jsx's EditBlock) shows
the same diff after an edit is applied, mirroring nexus-run.

Verified: 212 backend tests pass (29 new in test_self_edit.py; the 12
pre-existing C/C++/Rust toolchain failures are unrelated and unchanged),
57 frontend node:test cases pass (15 new), eslint and vite build clean,
and the full approval-preview render path was exercised against the real
built UI with a mocked SSE stream covering all four preview branches
(source diff, playbook becomes-main, settings policy-change, and a
rejected/failing preview).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 20:13:35 -05:00
co-authored by Claude Sonnet 5
parent 50738b139a
commit 0bbe5e200e
12 changed files with 1343 additions and 49 deletions
+9 -31
View File
@@ -28,6 +28,7 @@ from .chat import generate_chat_response, stream_chat_response, _synapse_trace
from . import chat as _chat
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
from . import frontend_manager as _frontend_manager
from . import playbook_manager
from .playbook_manager import PlaybookManager
from . import tools as _tools
@@ -205,7 +206,7 @@ async def _generate_conversation_title(first_message: str, model: str) -> Option
from .memory.store import store, MemoryItem
from .playbooks.store import playbook_store, PlaybookItem
from .playbooks.store import playbook_store
from .search import needs_web_search, web_search
MEMORY_SERVICE = settings.memory_url
@@ -1031,37 +1032,14 @@ def _find_playbook_by_id(playbook_id: str) -> Tuple[Optional[Any], Optional[str]
def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Persist a playbook dict to the store by converting to PlaybookItem.
"""
"""Persist a playbook dict to the store. Thin wrapper over the shared,
mergeable implementation in playbook_manager — this call site always does a
full replace (merge=False), matching the frontend form's behavior of always
submitting a complete object."""
try:
# Preserve existing order on update; use provided order (or tail) on create
existing = playbook_store.get_playbook(str(playbook_dict["id"]))
order = existing.order if existing else playbook_dict.get("order", len(playbook_store.all_playbooks()))
playbook_item = PlaybookItem(
id=str(playbook_dict["id"]),
title=playbook_dict.get("title", ""),
goal=playbook_dict.get("goal", ""),
instructions=playbook_dict.get("instructions", ""),
tags=playbook_dict.get("tags", []),
tools=playbook_dict.get("tools", []),
model=playbook_dict.get("model", ""),
order=order
)
playbook_store.add_playbook(playbook_item)
# Return as dict
return {
"id": playbook_item.id,
"title": playbook_item.title,
"goal": playbook_item.goal,
"instructions": playbook_item.instructions,
"tags": playbook_item.tags,
"tools": playbook_item.tools,
"model": playbook_item.model,
}
return playbook_manager.persist_playbook(dict(playbook_dict), merge=False)
except (ValueError, RuntimeError) as e:
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")