forked from enderofwings/NexusOS
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:
+23
-6
@@ -10,6 +10,7 @@ from typing import AsyncGenerator, Dict, List, Optional, Any
|
||||
from .nexus_config import settings, DEFAULT_CHAT_MODEL
|
||||
from .ollama_manager import get_ollama_manager
|
||||
from . import tools as _tools
|
||||
from . import self_edit
|
||||
|
||||
# Cap on tool-call round-trips before the final answer — stops a confused small
|
||||
# model from looping forever.
|
||||
@@ -349,9 +350,17 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
messages.append(msg)
|
||||
|
||||
# If any action tool needs per-call approval, pause and wait for the user.
|
||||
# edit_playbook/edit_settings/edit_source always require it, regardless of
|
||||
# `policy` — a global "allow" set for convenience on an unrelated tool
|
||||
# (web_search, say) must never silently also unlock unattended
|
||||
# self-modification. See self_edit.ALWAYS_ASK_TOOLS.
|
||||
decisions = None
|
||||
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
|
||||
if policy == "ask" and action_calls:
|
||||
needs_approval = policy == "ask" or any(
|
||||
c.get("function", {}).get("name", "") in self_edit.ALWAYS_ASK_TOOLS
|
||||
for c in action_calls
|
||||
)
|
||||
if needs_approval and action_calls:
|
||||
event = asyncio.Event()
|
||||
# Single-use capability token, delivered only to the client that owns
|
||||
# this stream. /chat/approve requires it, so knowing the (guessable,
|
||||
@@ -359,13 +368,21 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
# else's pending action.
|
||||
token = secrets.token_urlsafe(32)
|
||||
pending_approvals[conversation_id] = {"event": event, "decisions": {}, "token": token}
|
||||
|
||||
def _action_entry(c):
|
||||
name = c.get("function", {}).get("name", "")
|
||||
args = c.get("function", {}).get("arguments")
|
||||
entry = {"name": name, "arguments": args}
|
||||
if name in self_edit.PREVIEWABLE:
|
||||
try:
|
||||
entry["preview"] = self_edit.preview_for(name, args or {})
|
||||
except Exception as e:
|
||||
entry["preview"] = {"ok": False, "error": f"preview failed: {e}"}
|
||||
return entry
|
||||
|
||||
yield "__approve__" + _json.dumps({
|
||||
"token": token,
|
||||
"actions": [
|
||||
{"name": c.get("function", {}).get("name", ""),
|
||||
"arguments": c.get("function", {}).get("arguments")}
|
||||
for c in action_calls
|
||||
],
|
||||
"actions": [_action_entry(c) for c in action_calls],
|
||||
})
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=_APPROVAL_TIMEOUT)
|
||||
|
||||
Reference in New Issue
Block a user