forked from enderofwings/NexusOS
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>
84 lines
3.6 KiB
Python
84 lines
3.6 KiB
Python
from typing import List
|
|
from uuid import uuid4
|
|
from .playbooks.store import playbook_store, PlaybookItem
|
|
|
|
# Fields a caller can supply; anything else in a persist dict is ignored.
|
|
# `order` is deliberately excluded — see persist_playbook.
|
|
_EDITABLE_FIELDS = ("title", "goal", "instructions", "tags", "tools", "model")
|
|
_FIELD_DEFAULTS = {"title": "", "goal": "", "instructions": "", "tags": [], "tools": [], "model": ""}
|
|
|
|
|
|
def persist_playbook(data: dict, *, merge: bool) -> dict:
|
|
"""Write a playbook dict to the store, returning it as a plain dict.
|
|
|
|
`merge=False` is today's behavior (main.py's HTTP form handlers): a full
|
|
replace, with pydantic defaults for anything omitted. `merge=True` (used by
|
|
the edit_playbook tool) instead keeps each field's *existing* value when the
|
|
caller's dict doesn't supply it — a model calling with a partial argument
|
|
set must not silently blank out the fields it didn't mention.
|
|
|
|
`order` is never taken from `data` in merge mode: it is preserved from the
|
|
existing playbook on update, or appended at the tail on create. Position 0
|
|
is unconditionally the active system prompt (see get_main_playbook) — moving
|
|
a playbook there is `make_main`'s job, never an accidental side effect of an
|
|
ordinary field edit.
|
|
"""
|
|
existing_id = str(data.get("id") or "")
|
|
existing = playbook_store.get_playbook(existing_id) if existing_id else None
|
|
|
|
if merge:
|
|
fields = {}
|
|
for key in _EDITABLE_FIELDS:
|
|
if key in data and data[key] is not None:
|
|
fields[key] = data[key]
|
|
elif existing is not None:
|
|
fields[key] = getattr(existing, key)
|
|
else:
|
|
fields[key] = _FIELD_DEFAULTS[key]
|
|
if existing is None and not (fields["title"] and fields["goal"] and fields["instructions"]):
|
|
raise ValueError("title, goal, and instructions are required to create a new playbook")
|
|
else:
|
|
fields = {key: data.get(key, _FIELD_DEFAULTS[key]) for key in _EDITABLE_FIELDS}
|
|
|
|
order = existing.order if existing else data.get("order", len(playbook_store.all_playbooks()))
|
|
item = PlaybookItem(id=existing_id or str(uuid4()), order=order, **fields)
|
|
playbook_store.add_playbook(item)
|
|
return item.model_dump()
|
|
|
|
|
|
def make_main(playbook_id: str) -> None:
|
|
"""Reorder so `playbook_id` is position 0 (the active system prompt)."""
|
|
all_ids = [p.id for p in playbook_store.all_playbooks()]
|
|
if playbook_id not in all_ids:
|
|
raise ValueError(f"no playbook with id {playbook_id!r}")
|
|
ordered = [playbook_id] + [pid for pid in all_ids if pid != playbook_id]
|
|
playbook_store.reorder_playbooks(ordered)
|
|
|
|
|
|
class PlaybookManager:
|
|
@classmethod
|
|
def _all(cls) -> List[PlaybookItem]:
|
|
"""Return all playbooks sorted by order (position 0 is always main)."""
|
|
return playbook_store.all_playbooks()
|
|
|
|
@classmethod
|
|
def get_main_playbook(cls) -> PlaybookItem | None:
|
|
playbooks = cls._all()
|
|
return playbooks[0] if playbooks else None
|
|
|
|
@classmethod
|
|
def get_context_playbooks(cls) -> List[PlaybookItem]:
|
|
"""All playbooks after the first — injected as reference context."""
|
|
playbooks = cls._all()
|
|
return playbooks[1:] if len(playbooks) > 1 else []
|
|
|
|
@classmethod
|
|
def get_system_prompt(cls) -> str:
|
|
playbook = cls.get_main_playbook()
|
|
if not playbook:
|
|
return ""
|
|
goal = (getattr(playbook, "goal", "") or "").strip()
|
|
instructions = (getattr(playbook, "instructions", "") or "").strip()
|
|
if goal and instructions:
|
|
return f"{goal}\n\n{instructions}"
|
|
return goal or instructions |