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
+108 -6
View File
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { API_BASE } from "./config";
import { Markdown } from "./Markdown";
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
const [messages, setMessages] = useState([]);
@@ -728,13 +729,16 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
<div style={{ marginBottom: "0.5rem", padding: "0.7rem 0.9rem", background: "#2a2418", border: "1px solid #6a5a2a", borderRadius: "10px" }}>
<div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}>
The assistant wants to run:
{" "}
{pendingApproval.map((a, i) => (
<code key={i} style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem" }}>
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
</code>
))}
</div>
{pendingApproval.map((a, i) =>
a.preview
? <ActionPreview key={i} action={a} />
: (
<code key={i} style={{ display: "inline-block", color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem", marginBottom: "0.4rem" }}>
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
</code>
)
)}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button onClick={() => resolveApproval(true)}
style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
@@ -817,4 +821,102 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
</div>
</div>
);
}
// One self-edit tool's approval preview: a real, server-computed diff instead
// of the flat "name(arg, arg)" one-liner used for every other action tool.
// This is what makes "always ask first" mean actual informed consent for
// edit_source/edit_playbook/edit_settings — the human reviews what will
// actually change, not a description of it. See synapse/self_edit.py's
// preview_* functions, which compute exactly what's rendered here.
function ActionPreview({ action }) {
const { name, preview } = action;
const banner = (text) => (
<div style={{
padding: "0.4rem 0.6rem", marginBottom: "0.4rem", background: "#3a2a10",
border: "1px solid #8a6a2a", borderRadius: "6px", color: "#ffd580",
fontSize: "0.8rem", fontWeight: 600,
}}>
{text}
</div>
);
const diffBox = (lines) => (
<pre style={{
background: "#0d0d0d", border: "1px solid #333", borderRadius: "6px",
padding: "0.5rem 0.7rem", margin: "0 0 0.4rem", fontSize: "0.8rem",
lineHeight: "1.4", maxHeight: "16rem", overflow: "auto",
}}>
{lines.length === 0 && <span style={{ color: "#666" }}>(no changes)</span>}
{lines.map((l, i) => (
<div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{l.text || " "}
</div>
))}
</pre>
);
if (!preview.ok) {
return (
<div style={{ marginBottom: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>{name}</code>
{" — this will fail:"}
</div>
<div style={{ color: "#ff8a80", fontSize: "0.8rem", marginBottom: "0.4rem" }}>{preview.error}</div>
</div>
);
}
if (name === "edit_source") {
return (
<div style={{ marginBottom: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_source</code>
{" "}{preview.path}{preview.is_new_file ? " (new file)" : ""}
</div>
{diffBox(diffLines(preview.diff))}
</div>
);
}
if (name === "edit_playbook") {
const keys = ["title", "goal", "instructions", "tags", "tools", "model"];
const lines = keyValueDiffLines(preview.before, preview.after, keys);
return (
<div style={{ marginBottom: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_playbook</code>
{" "}{preview.is_new ? "(new playbook)" : preview.after?.title}
</div>
{preview.becomes_main_playbook && banner("this will become the active system prompt")}
{diffBox(lines)}
</div>
);
}
if (name === "edit_settings") {
const before = {}, after = {};
for (const [k, v] of Object.entries(preview.applied || {})) {
before[k] = v.before;
after[k] = v.after;
}
const lines = keyValueDiffLines(before, after, Object.keys(preview.applied || {}));
return (
<div style={{ marginBottom: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_settings</code>
</div>
{preview.policy_change && banner("this changes the tool-approval policy itself")}
{preview.system_prompt_change && banner("this changes the fallback system prompt")}
{diffBox(lines)}
{preview.ignored_unknown && preview.ignored_unknown.length > 0 && (
<div style={{ fontSize: "0.75rem", color: "#888" }}>
ignored (not a real setting): {preview.ignored_unknown.join(", ")}
</div>
)}
</div>
);
}
return null;
}
+117
View File
@@ -9,6 +9,11 @@ import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
// The execution track's display half: a ```nexus-run fence is a run result
// (source + captured output), not a program to execute here. See run-langs.js.
import { RUN_FENCE_LANG, parseRunResult } from "./preview/run-langs.js";
// Self-modification's display half: a ```nexus-edit fence is the result of an
// already-applied, already-approved edit_source/edit_playbook/edit_settings
// call. See self-edit-langs.js.
import { EDIT_FENCE_LANG, parseEditResult } from "./preview/self-edit-langs.js";
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
// Parse content into an array of {type, value, lang, streaming} blocks.
// Handles:
@@ -72,6 +77,10 @@ export function Markdown({ content }) {
const run = block.streaming ? null : parseRunResult(block.value);
if (run) return <RunBlock key={i} run={run} />;
}
if (lang === EDIT_FENCE_LANG) {
const edit = block.streaming ? null : parseEditResult(block.value);
if (edit) return <EditBlock key={i} edit={edit} />;
}
return RENDERABLE_LANGS.has(lang)
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
: <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
@@ -252,6 +261,114 @@ function RunBlock({ run }) {
);
}
// A finished self-edit: the diff/change that was actually applied, plus (for
// edit_source) the git commit it landed as. Like RunBlock, nothing executes
// here — the edit already happened on the backend under approval, and by the
// time this renders it's history. Styled the same way as CodeBlock/RunBlock
// for visual consistency, with diff lines colored via the shared
// diff-view.js vocabulary rather than a syntax-highlighting library.
function EditBlock({ edit }) {
const [copied, setCopied] = useState(false);
const copyText =
edit.kind === "source" ? edit.diff
: edit.kind === "playbook" ? edit.instructions
: JSON.stringify(edit.applied, null, 2);
const copy = () => {
navigator.clipboard.writeText((copyText || "").trimEnd()).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
const title =
edit.kind === "source" ? `edited ${edit.path}`
: edit.kind === "playbook" ? `playbook: ${edit.title || edit.id}`
: "settings changed";
const lines =
edit.kind === "source" ? diffLines(edit.diff)
: edit.kind === "settings"
? keyValueDiffLines(
Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.before])),
Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.after])),
Object.keys(edit.applied),
)
: [];
return (
<div style={{
background: "#0d0d0d",
border: "1px solid #2a2a2a",
borderRadius: "6px",
margin: "0.5rem 0",
overflow: "hidden",
}}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.3rem 0.75rem",
background: "#161616",
borderBottom: "1px solid #2a2a2a",
}}>
<span style={{ fontSize: "0.75rem", color: "#888", fontFamily: "monospace" }}>
{title}
</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{edit.commit && (
<span style={{ fontSize: "0.7rem", fontFamily: "monospace", color: "#7a92a8" }}>
commit {edit.commit}
</span>
)}
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
{copied ? "Copied!" : "Copy"}
</button>
</div>
</div>
{edit.kind === "playbook" && edit.isMainPlaybook && (
<div style={{
padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
}}>
this is now the active system prompt
</div>
)}
{(edit.kind === "settings") && (edit.policyChange || edit.systemPromptChange) && (
<div style={{
padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
}}>
{edit.policyChange && "changed the tool-approval policy"}
{edit.policyChange && edit.systemPromptChange && " and "}
{edit.systemPromptChange && "changed the fallback system prompt"}
</div>
)}
{edit.kind === "playbook" ? (
<pre style={{
padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
lineHeight: "1.5", margin: 0, fontFamily: "monospace", color: "#ccc",
}}>
<code>{(edit.instructions || "").trimEnd()}</code>
</pre>
) : (
<pre style={{
padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
lineHeight: "1.5", margin: 0, fontFamily: "monospace",
maxHeight: "24rem", overflowY: "auto",
}}>
{lines.map((l, i) => (
<div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{l.text || " "}
</div>
))}
</pre>
)}
</div>
);
}
// Content-Security-Policy for the rendered preview. Together with the iframe's
// `sandbox` attribute below, this is the entire trust boundary for model-
// authored HTML/SVG, so it stays conservative rather than convenient:
+56
View File
@@ -0,0 +1,56 @@
/*
* diff-view.js — shared plain-text diff-line classification, no dependency.
*
* There is no diff/syntax-highlighting library in this project (see
* package.json), so a unified-diff string is colored by hand: split into
* lines, tag each by its leading character. Used by both the self-edit
* approval banner (Chatbot.jsx, reviewing a change BEFORE it's applied) and
* the nexus-edit result block (Markdown.jsx, showing one AFTER) — one small
* shared vocabulary so both read the same way.
*/
/**
* Split unified-diff text (from difflib.unified_diff on the backend) into
* {text, kind} lines. kind is one of "add" | "remove" | "hunk" | "file" |
* "context". The "+++"/"---" file markers and "@@" hunk headers get their own
* kind so they aren't colored as if they were real added/removed lines (a
* bare "+++" line is not "code that was added").
*/
export function diffLines(text) {
return (text || "").replace(/\n$/, "").split("\n").map((line) => {
if (line.startsWith("+++") || line.startsWith("---")) return { text: line, kind: "file" };
if (line.startsWith("@@")) return { text: line, kind: "hunk" };
if (line.startsWith("+")) return { text: line, kind: "add" };
if (line.startsWith("-")) return { text: line, kind: "remove" };
return { text: line, kind: "context" };
});
}
export const DIFF_LINE_COLOR = {
add: "#8aff8a",
remove: "#ff8a80",
hunk: "#7a92a8",
file: "#7a92a8",
context: "#ccc",
};
/**
* before/after key-value pairs (playbook fields, settings keys) rendered
* through the same add/remove vocabulary as a real diff, so a settings or
* playbook change reads the same way a source diff does: one line removed,
* one line added, per changed key. Unchanged keys are omitted entirely —
* this is "what's different," not a full dump.
*/
export function keyValueDiffLines(before, after, keys) {
const lines = [];
for (const key of keys) {
const b = before ? before[key] : undefined;
const a = after ? after[key] : undefined;
const bStr = JSON.stringify(b);
const aStr = JSON.stringify(a);
if (bStr === aStr) continue;
if (b !== undefined) lines.push({ text: `- ${key}: ${bStr}`, kind: "remove" });
if (a !== undefined) lines.push({ text: `+ ${key}: ${aStr}`, kind: "add" });
}
return lines;
}
@@ -0,0 +1,45 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffLines, keyValueDiffLines } from "./diff-view.js";
test("diffLines tags add/remove/hunk/file/context lines", () => {
const text = "--- a/x.py\n+++ b/x.py\n@@ -1,1 +1,1 @@\n-old\n+new\n unchanged\n";
const lines = diffLines(text);
assert.equal(lines[0].kind, "file");
assert.equal(lines[1].kind, "file");
assert.equal(lines[2].kind, "hunk");
assert.equal(lines[3].kind, "remove");
assert.equal(lines[4].kind, "add");
assert.equal(lines[5].kind, "context");
});
test("diffLines drops exactly one trailing newline, not trailing blank lines", () => {
const lines = diffLines("a\nb\n");
assert.equal(lines.length, 2);
assert.equal(lines[1].text, "b");
});
test("diffLines on empty text returns one empty context line, not a crash", () => {
const lines = diffLines("");
assert.equal(lines.length, 1);
assert.equal(lines[0].text, "");
});
test("keyValueDiffLines only emits changed keys", () => {
const lines = keyValueDiffLines({ a: 1, b: 2 }, { a: 1, b: 3 }, ["a", "b"]);
assert.equal(lines.length, 2);
assert.match(lines[0].text, /^- b: 2$/);
assert.match(lines[1].text, /^\+ b: 3$/);
});
test("keyValueDiffLines handles a null before (brand-new object)", () => {
const lines = keyValueDiffLines(null, { title: "New" }, ["title"]);
assert.equal(lines.length, 1);
assert.equal(lines[0].kind, "add");
assert.match(lines[0].text, /title: "New"/);
});
test("keyValueDiffLines emits nothing when nothing changed", () => {
assert.deepEqual(keyValueDiffLines({ a: 1 }, { a: 1 }, ["a"]), []);
});
@@ -0,0 +1,65 @@
/*
* self-edit-langs.js — display half of the self-modification tools
* (edit_source / edit_playbook / edit_settings), the counterpart to
* run-langs.js. Nothing executes or applies here: by the time this parses a
* fence, the write (if any) already happened on the backend under the user's
* per-call approval, in synapse/self_edit.py. This side only labels and lays
* out what was returned.
*
* One fence tag, three payload shapes (source / playbook / settings), because
* all three go through the same approval round-trip and the same "carry what
* happened in one block" idea as run_snippet's nexus-run fence.
*/
export const EDIT_FENCE_LANG = "nexus-edit";
/**
* Parse a nexus-edit fence body. Returns null for anything malformed or of an
* unrecognized kind — the caller falls back to showing the block as plain
* code, the same honest-fallback behavior as parseRunResult.
*/
export function parseEditResult(text) {
let data;
try {
data = JSON.parse(text);
} catch {
return null;
}
if (!data || typeof data !== "object") return null;
if (data.kind === "source") {
if (typeof data.path !== "string" || typeof data.diff !== "string") return null;
return {
kind: "source",
path: data.path,
diff: data.diff,
commit: typeof data.commit === "string" ? data.commit : null,
instruction: typeof data.instruction === "string" ? data.instruction : "",
};
}
if (data.kind === "playbook") {
if (typeof data.id !== "string") return null;
return {
kind: "playbook",
id: data.id,
title: typeof data.title === "string" ? data.title : "",
goal: typeof data.goal === "string" ? data.goal : "",
instructions: typeof data.instructions === "string" ? data.instructions : "",
isMainPlaybook: !!data.is_main_playbook,
};
}
if (data.kind === "settings") {
if (!data.applied || typeof data.applied !== "object") return null;
return {
kind: "settings",
applied: data.applied,
ignoredUnknown: Array.isArray(data.ignored_unknown) ? data.ignored_unknown : [],
policyChange: !!data.policy_change,
systemPromptChange: !!data.system_prompt_change,
};
}
return null;
}
@@ -0,0 +1,80 @@
/*
* The self-edit result envelope: what parseEditResult will and won't accept.
*
* Same reasoning as run-langs.test.js: this parses text a model chose to
* paste, so the malformed cases matter as much as the well-formed ones. A bad
* envelope must return null so the caller falls back to plain code, not a
* half-built object that renders a change that never happened.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { EDIT_FENCE_LANG, parseEditResult } from "./self-edit-langs.js";
test("the fence tag is the one the backend emits", () => {
assert.equal(EDIT_FENCE_LANG, "nexus-edit");
});
test("a well-formed source envelope parses into display fields", () => {
const edit = parseEditResult(JSON.stringify({
kind: "source", ok: true, path: "synapse/tools.py", diff: "--- a\n+++ b\n",
commit: "abc1234", instruction: "restart needed",
}));
assert.equal(edit.kind, "source");
assert.equal(edit.path, "synapse/tools.py");
assert.equal(edit.commit, "abc1234");
assert.equal(edit.instruction, "restart needed");
});
test("a source envelope with no commit reads as null, not undefined", () => {
const edit = parseEditResult(JSON.stringify({
kind: "source", path: "x.py", diff: "", commit: null,
}));
assert.equal(edit.commit, null);
});
test("a source envelope missing path or diff is rejected", () => {
assert.equal(parseEditResult(JSON.stringify({ kind: "source", diff: "x" })), null);
assert.equal(parseEditResult(JSON.stringify({ kind: "source", path: "x.py" })), null);
});
test("a well-formed playbook envelope parses into display fields", () => {
const edit = parseEditResult(JSON.stringify({
kind: "playbook", id: "p1", title: "T", goal: "G", instructions: "I",
is_main_playbook: true,
}));
assert.equal(edit.kind, "playbook");
assert.equal(edit.id, "p1");
assert.equal(edit.isMainPlaybook, true);
});
test("a playbook envelope missing id is rejected", () => {
assert.equal(parseEditResult(JSON.stringify({ kind: "playbook", title: "T" })), null);
});
test("a well-formed settings envelope parses into display fields", () => {
const edit = parseEditResult(JSON.stringify({
kind: "settings",
applied: { model: { before: "a", after: "b" } },
ignored_unknown: ["nope"],
policy_change: true,
system_prompt_change: false,
}));
assert.equal(edit.kind, "settings");
assert.deepEqual(edit.applied, { model: { before: "a", after: "b" } });
assert.deepEqual(edit.ignoredUnknown, ["nope"]);
assert.equal(edit.policyChange, true);
assert.equal(edit.systemPromptChange, false);
});
test("a settings envelope missing applied is rejected", () => {
assert.equal(parseEditResult(JSON.stringify({ kind: "settings" })), null);
});
test("malformed or foreign envelopes are rejected", () => {
assert.equal(parseEditResult("not json"), null);
assert.equal(parseEditResult("null"), null);
assert.equal(parseEditResult("[1,2,3]"), null);
assert.equal(parseEditResult(JSON.stringify({ kind: "unknown_kind" })), null);
assert.equal(parseEditResult(JSON.stringify({ path: "x.py", diff: "" })), null);
});