Files
NexusOS/interface/web/src/preview/self-edit-langs.js
T
AthenaandClaude Sonnet 5 0bbe5e200e 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>
2026-08-25 20:13:35 -05:00

66 lines
2.2 KiB
JavaScript

/*
* 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;
}