feat(dream): add skill ownership guard with dream_managed frontmatter

- Dream-created skills get  in SKILL.md frontmatter
-  tags each skill as [dream]/[user]/[builtin]
- New config  (default False): when False Dream
  can only modify [dream] skills; when True [user] skills are also editable
- [builtin] skills are never editable
- dream.md prompt uses Jinja conditional to render edit policy
- WebUI types and test fixtures updated with new config field
This commit is contained in:
chengyongru 2026-05-26 00:50:45 +08:00
parent 376d82fec7
commit f1250f0d16
8 changed files with 54 additions and 9 deletions

View File

@ -1168,6 +1168,7 @@ class AgentLoop:
user_path=str(workspace / "USER.md"),
memory_path=str(workspace / "memory" / "MEMORY.md"),
stale_threshold_days=_STALE_THRESHOLD_DAYS,
dream_edit_user_skills=self.dream.edit_user_skills,
)
session.metadata["_dream_system_prompt"] = cached_prompt
session.metadata["_dream_system_prompt_mtime"] = current_mtime
@ -1220,7 +1221,7 @@ class AgentLoop:
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
)
existing_skills = self.dream._list_existing_skills()
existing_skills = self.dream._list_existing_skills(tag_origin=True)
skills_section = ""
if existing_skills:
skills_section = (

View File

@ -986,6 +986,7 @@ class Dream:
max_iterations: int = 10,
max_tool_result_chars: int = 16_000,
annotate_line_ages: bool = True,
edit_user_skills: bool = False,
):
self.store = store
self.provider = provider
@ -997,6 +998,9 @@ class Dream:
# Default True keeps the #3212 behavior; set False to feed all memory
# files raw (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
self.annotate_line_ages = annotate_line_ages
# When True, Dream may edit/delete user-created workspace skills.
# When False, only skills with dream_managed: true in frontmatter are editable.
self.edit_user_skills = edit_user_skills
self._runner = AgentRunner(provider)
self._tools = self._build_tools()
@ -1038,15 +1042,25 @@ class Dream:
# -- skill listing --------------------------------------------------------
def _list_existing_skills(self) -> list[str]:
"""List existing skills as 'name — description' for dedup context."""
def _list_existing_skills(self, tag_origin: bool = False) -> list[str]:
"""List existing skills as 'name — description [origin]' for dedup context.
When *tag_origin* is True each entry gets an origin tag:
``[dream]`` for skills with ``dream_managed: true`` in frontmatter,
``[user]`` for other workspace skills, ``[builtin]`` for bundled skills.
"""
import re as _re
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
desc_re = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
entries: dict[str, str] = {}
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
managed_re = _re.compile(r"^dream_managed:\s*true$", _re.MULTILINE | _re.IGNORECASE)
entries: dict[str, tuple[str, str]] = {} # name -> (desc, tag)
builtin_dir = BUILTIN_SKILLS_DIR
ws_skills_dir = self.store.workspace / "skills"
for base in (ws_skills_dir, builtin_dir):
if not base.exists():
continue
for d in base.iterdir():
@ -1056,13 +1070,26 @@ class Dream:
if not skill_md.exists():
continue
# Prefer workspace skills over builtin (same name)
if d.name in entries and base == BUILTIN_SKILLS_DIR:
if d.name in entries and base == builtin_dir:
continue
content = skill_md.read_text(encoding="utf-8")[:500]
m = desc_re.search(content)
desc = m.group(1).strip() if m else "(no description)"
entries[d.name] = desc
return [f"{name}{desc}" for name, desc in sorted(entries.items())]
if tag_origin:
if base == builtin_dir:
tag = "[builtin]"
elif managed_re.search(content):
tag = "[dream]"
else:
tag = "[user]"
entries[d.name] = (desc, tag)
else:
entries[d.name] = (desc, "")
if tag_origin:
return [f"{name}{desc} {tag}" for name, (desc, tag) in sorted(entries.items())]
return [f"{name}{desc}" for name, (desc, _) in sorted(entries.items())]
# -- main entry ----------------------------------------------------------

View File

@ -1030,6 +1030,7 @@ def _run_gateway(
agent.dream.max_batch_size = dream_cfg.max_batch_size
agent.dream.max_iterations = dream_cfg.max_iterations
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
agent.dream.edit_user_skills = dream_cfg.dream_edit_user_skills
from nanobot.cron.types import CronJob, CronPayload
cron.register_system_job(CronJob(
id="dream",

View File

@ -59,6 +59,10 @@ class DreamConfig(Base):
# on — set to False to feed all memory files raw if a specific LLM reacts
# poorly to the `← Nd` suffix or you want deterministic, git-independent prompts.
annotate_line_ages: bool = True
# When False (default), Dream may only modify skills it created (marked
# dream_managed in frontmatter). When True, Dream may also edit user-created
# workspace skills. Builtin skills are never editable.
dream_edit_user_skills: bool = False
def build_schedule(self, timezone: str) -> CronSchedule:
"""Build the runtime schedule, preferring the legacy cron override if present."""

View File

@ -45,10 +45,17 @@ Flag [SKILL] only when ALL are true: repeatable workflow appeared 2+ times, invo
For [SKILL] entries:
- Use write_file to create skills/<name>/SKILL.md; read_file `{{ skill_creator_path }}` for format reference
- YAML frontmatter (name, description), under 2000 words: when to use, steps, output format, example
- YAML frontmatter must include name, description, **and `dream_managed: true`** (marks this skill as Dream-created)
- Under 2000 words: when to use, steps, output format, example
- Do NOT overwrite existing skills — if overlapping, merge delta into the existing skill
- Skills are instruction sets, not code. Keep concrete values in MEMORY.md; skills use placeholders
## Skill edit policy
Each skill in the Existing Skills list is tagged with an origin:
- **[dream]** — Dream-created (has `dream_managed: true` in frontmatter). You MAY edit these.
- **[user]** — User-created workspace skill. {% if dream_edit_user_skills %}You MAY edit these.{% else %}You MUST NOT modify, rename, or delete these — you can only read them for context.{% endif %}
- **[builtin]** — Bundled with nanobot. You MUST NEVER modify these.
## Editing
- Default tool: apply_patch. Use edit_file only for small exact replacements.
- File contents provided below — no read_file needed for initial edits.

View File

@ -307,6 +307,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
"max_batch_size": defaults.dream.max_batch_size,
"max_iterations": defaults.dream.max_iterations,
"annotate_line_ages": defaults.dream.annotate_line_ages,
"dream_edit_user_skills": defaults.dream.dream_edit_user_skills,
},
"unified_session": defaults.unified_session,
},

View File

@ -265,6 +265,7 @@ export interface SettingsPayload {
max_batch_size: number;
max_iterations: number;
annotate_line_ages: boolean;
dream_edit_user_skills: boolean;
};
unified_session: boolean;
};

View File

@ -92,6 +92,7 @@ function baseSettingsPayload() {
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
dream_edit_user_skills: false,
},
unified_session: false,
},
@ -780,6 +781,7 @@ describe("App layout", () => {
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
dream_edit_user_skills: false,
},
unified_session: false,
},
@ -1066,6 +1068,7 @@ describe("App layout", () => {
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
dream_edit_user_skills: false,
},
unified_session: false,
},