#4667
fix: protect user skills from dream writes
Summary
- add a Dream-only write guard for workspace skills
- require
dream_managed: truefrontmatter before Dream can modify existing skills - allow Dream-created skills only when the initial
SKILL.mdcarries that marker
Fixes #4075
Verification
uv run pytest tests/agent/test_memory_store.py::TestMemoryStoreBasicIO::test_dream_write_tools_block_unmarked_user_skill tests/agent/test_memory_store.py::TestMemoryStoreBasicIO::test_dream_write_tools_allow_marked_skill tests/agent/test_memory_store.py::TestMemoryStoreBasicIO::test_dream_can_create_only_marked_skill -quv run ruff check nanobot/agent/memory.py nanobot/agent/tools/filesystem.py nanobot/agent/tools/apply_patch.py tests/agent/test_memory_store.py
Activity
8Review — fix: protect user skills from dream writes
What this PR does: Adds a write_guard: Callable[[Path, str | None], str | None] | None parameter to WriteFileTool, EditFileTool, and ApplyPatchTool. The guard returns None to allow or a string message to deny (raised as PermissionError). MemoryStore._dream_skill_write_guard enforces:
- If target skill's
SKILL.mdalready exists: must havedream_managed: truein YAML frontmatter. - If creating new
SKILL.md: the content being written must havedream_managed: truefrontmatter. - Path must be inside a named skill directory (
len(parts) >= 2).
Guard is checked in all three tools' execute paths, plus ApplyPatchTool checks each write in the patch.
What's good
- Ownership enforced at the write sink, not in prompts. This is the core fix #4075 asked for. Even if Dream's prompt is bypassed or the model hallucinates, the tool itself rejects the write.
- Frontmatter parsing is conservative. Requires
---on first line, scans until closing---, looks fordream_managed: true(lowercase). BOM-stripped. Doesn't accept quoted"true"orTrue— strict. - Allows Dream to create new marked skills.
test_dream_can_create_only_marked_skillverifies unmarked creation is blocked, marked creation succeeds. Correct positive path. - All three write tools guarded. No bypass via
apply_patchoredit_file. ApplyPatchToolchecks each write in the patch (line 268:for path, content in writes.items(): self._check_write_guard(path, content)). Catches multi-file patches that include skill files.- Existing dream tests updated to include
dream_managed: truein their fixture content. Backwards-compatible with the test suite.
Concerns / questions
dream_managed: "true"(quoted) is NOT recognized._skill_content_is_dream_manageddoesvalue.strip().lower() == "true"— quoted values would be'"true"'after strip, which fails the check. Users writing YAML frontmatter with quoted strings would be surprised. Either document "must be unquotedtrue" or accept quoted forms.dream_managed: True(YAML boolean) IS recognized becausevalue.strip().lower() == "true"accepts case-insensitively. Good.- No support for
dream_managed: 1or other truthy values. Probably fine — strictness is good for a security marker. _dream_skill_write_guardreturnsNone(allow) if path can't be resolved relative toskills_dir. This is safe because the tool's ownallowed_dircheck still applies — the guard is layered ON TOP of existing path restrictions, not a replacement. But it means the guard doesn't FAIL CLOSED on unexpected errors. For a security boundary, failing closed (return "could not verify ownership") might be safer. Worth discussing.- Frontmatter parser doesn't handle multi-line values or nested keys. For the current single-key marker, this is fine. If the marker schema grows, switch to a real YAML parser.
- No test for the BOM case. The
lstrip("\ufeff")is defensive but untested. Minor. - No test for traversal-style attacks like
skills/../user-skill/SKILL.md. Thetarget.relative_to(skills_dir)would resolve this correctly, but a test would lock it in.
Verdict
Approve with follow-up. The security boundary is correctly enforced. The quoted-"true" and fail-closed questions are worth a follow-up issue but don't block merge.
chengyongrureviewed4mo ago
Automated PR review by nanobot. This is not a human maintainer review or approval.
I found one ownership-boundary issue worth addressing.
hamb1yreviewed4mo ago
hamb1yreviewed4mo ago
Review — fix: protect user skills from dream writes
What this PR does: Adds a
write_guard: Callable[[Path, str | None], str | None] | Noneparameter toWriteFileTool,EditFileTool, andApplyPatchTool. The guard returnsNoneto allow or a string message to deny (raised asPermissionError).MemoryStore._dream_skill_write_guardenforces:SKILL.mdalready exists: must havedream_managed: truein YAML frontmatter.SKILL.md: the content being written must havedream_managed: truefrontmatter.len(parts) >= 2).Guard is checked in all three tools' execute paths, plus
ApplyPatchToolchecks each write in the patch.What's good
---on first line, scans until closing---, looks fordream_managed: true(lowercase). BOM-stripped. Doesn't accept quoted"true"orTrue— strict.test_dream_can_create_only_marked_skillverifies unmarked creation is blocked, marked creation succeeds. Correct positive path.apply_patchoredit_file.ApplyPatchToolchecks each write in the patch (line 268:for path, content in writes.items(): self._check_write_guard(path, content)). Catches multi-file patches that include skill files.dream_managed: truein their fixture content. Backwards-compatible with the test suite.Concerns / questions
dream_managed: "true"(quoted) is NOT recognized._skill_content_is_dream_manageddoesvalue.strip().lower() == "true"— quoted values would be'"true"'after strip, which fails the check. Users writing YAML frontmatter with quoted strings would be surprised. Either document "must be unquotedtrue" or accept quoted forms.dream_managed: True(YAML boolean) IS recognized becausevalue.strip().lower() == "true"accepts case-insensitively. Good.dream_managed: 1or other truthy values. Probably fine — strictness is good for a security marker._dream_skill_write_guardreturnsNone(allow) if path can't be resolved relative toskills_dir. This is safe because the tool's ownallowed_dircheck still applies — the guard is layered ON TOP of existing path restrictions, not a replacement. But it means the guard doesn't FAIL CLOSED on unexpected errors. For a security boundary, failing closed (return "could not verify ownership") might be safer. Worth discussing.lstrip("\ufeff")is defensive but untested. Minor.skills/../user-skill/SKILL.md. Thetarget.relative_to(skills_dir)would resolve this correctly, but a test would lock it in.Verdict
Approve with follow-up. The security boundary is correctly enforced. The quoted-
"true"and fail-closed questions are worth a follow-up issue but don't block merge.