fix(skills): close the runtime_data manifest-first skill-create bypass

The v6.38.0 cumulative-with-plan review found a sibling of the light skill-create gate left open: an
explicit write_file(root=runtime_data, path=skills/external/new/plugin.py) WITHOUT bucket/skill_name
skipped decide_payload_short_form (which early-returns on empty short-form args) and _data_write then
resolved the skills path + mkdir+wrote into a NON-existent payload — bypassing the manifest-first typo
guard the bucket/skill_name short-form enforces.

Factor the rule into a shared predicate is_skill_create_typo(payload_root, bucket, rel_within_payload)
in skill_payload_policy.py (SSOT: a missing payload that is NOT the root manifest of a new external
skill is a typo to block). decide_payload_short_form now calls it (replacing its inline rule,
behavior-identical), and _data_write applies it on the explicit skills path via a single resolver
_data_skill_target (which _data_skill_path now delegates to, so resolution is not duplicated). The
guard runs on the NORMALIZED write_path — the exact path the write targets — so a redundant drive-root
or .tmp-data-* prefix can no longer be stripped past the guard into a real payload. It fires ONLY for a
missing payload + non-manifest + non-external; an existing payload, a new external manifest, and
non-skill runtime_data writes (logs/state/memory) are unaffected. Golden test drives the real
registry.execute(write_file, root=runtime_data) end-to-end including the normalized-prefix case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ouroboros 2026-06-19 14:16:37 +03:00
parent ae3b40ffd5
commit 61ccaff9d5
3 changed files with 96 additions and 4 deletions

View file

@ -460,6 +460,18 @@ def _is_skill_create_signal(path_text: str) -> bool:
return "/" not in raw and raw.lower() in _SKILL_MANIFEST_BASENAMES
def is_skill_create_typo(*, payload_root: Path, bucket: str, rel_within_payload: str) -> bool:
"""Manifest-first typo guard SSOT, shared by the bucket/skill_name short-form and the explicit
``runtime_data`` ``skills/<bucket>/<skill>/...`` write path. A write into a NON-existent payload
is a typo for an arbitrary file, but a legitimate NEW skill when it IS the root manifest
(SKILL.md/skill.json) under bucket=external. Returns True when the write must be BLOCKED (missing
payload, not a new-external-skill manifest) so neither entry point can silently mkdir a bogus
payload from a misspelled name; writing into an EXISTING payload is always allowed."""
if payload_root.is_dir():
return False
return not (bucket == "external" and _is_skill_create_signal(rel_within_payload))
def decide_payload_short_form(
*,
bucket: str,
@ -494,7 +506,8 @@ def decide_payload_short_form(
# CREATE-from-scratch is for AGENT-authored skills only: the `external` bucket. The marketplace
# buckets (clawhub/ouroboroshub) are installed FROM the marketplace, never authored into a
# missing payload, so a missing marketplace payload stays an error (install it, don't create).
if not payload_root.is_dir() and not (clean_bucket == "external" and _is_skill_create_signal(path_text)):
# SSOT with the explicit runtime_data path via is_skill_create_typo (path_text is payload-relative).
if is_skill_create_typo(payload_root=payload_root, bucket=clean_bucket, rel_within_payload=path_text):
return PayloadShortFormDecision(
error=(
f"skill payload not found: {synth.payload_root}. "

View file

@ -34,11 +34,13 @@ from ouroboros.contracts.skill_payload_policy import (
SKILL_PAYLOAD_ALL_BUCKETS,
SKILL_OWNER_STATE_FILENAMES,
SkillPayloadPathError,
SkillPayloadTarget,
cross_skill_redirect_error,
decide_payload_short_form,
is_skill_control_plane_path as _policy_is_skill_control_plane_path,
is_skill_owner_state_alias,
is_skill_owner_state_target as _policy_is_skill_owner_state_target,
is_skill_create_typo,
resolve_skill_payload_target,
)
@ -109,13 +111,21 @@ def _native_payload_without_seed(target: pathlib.Path, data_root: pathlib.Path)
return bucket == "native" and not (payload_root / ".seed-origin").is_file()
def _data_skill_path(path: str, drive_root: pathlib.Path) -> pathlib.Path | None:
def _data_skill_target(path: str, drive_root: pathlib.Path) -> SkillPayloadTarget | None:
"""Single resolver for an explicit data-plane skills/<bucket>/<skill>/... write target (None when
the path is not inside a skill payload). SSOT for both _data_skill_path and the _data_write
manifest-first typo guard, so the payload resolution is never duplicated."""
try:
return resolve_skill_payload_target(pathlib.Path(drive_root), path).target_path
return resolve_skill_payload_target(pathlib.Path(drive_root), path)
except SkillPayloadPathError:
return None
def _data_skill_path(path: str, drive_root: pathlib.Path) -> pathlib.Path | None:
target = _data_skill_target(path, drive_root)
return target.target_path if target is not None else None
def _looks_like_serialized_tool_result(content: Any) -> bool:
text = str(content or "").lstrip()
if not (text.startswith("{'content'") or text.startswith('{"content"')):
@ -580,7 +590,25 @@ def _data_write(
except ValueError as e:
return f"⚠️ DATA_WRITE_ERROR: {e}"
else:
explicit_skill_target = _data_skill_path(path, pathlib.Path(ctx.drive_root))
# Resolve the skills target on the NORMALIZED write_path (the exact path the write uses below)
# so the manifest-first typo guard can never be skipped by a redundant drive-root / .tmp-data-*
# prefix that _normalize_data_read_path would later strip into a real skills/<bucket>/<skill>.
_skill_target = _data_skill_target(write_path, pathlib.Path(ctx.drive_root))
explicit_skill_target = None
if _skill_target is not None:
# Manifest-first typo guard (SSOT with the bucket/skill_name short-form via
# is_skill_create_typo): an explicit runtime_data write into a NON-existent
# skills/<bucket>/<skill> payload is a typo unless it is the root manifest of a NEW
# external skill — never silently mkdir a bogus payload from a misspelled name.
if is_skill_create_typo(payload_root=_skill_target.payload_root, bucket=_skill_target.bucket,
rel_within_payload=_skill_target.rel_path):
return (
f"⚠️ DATA_WRITE_ERROR: skill payload not found: "
f"skills/{_skill_target.bucket}/{_skill_target.skill}. Use an existing skill; for a "
"NEW skill write its manifest (SKILL.md/skill.json) at the payload root under "
"bucket=external; this path looks like a typo into a missing payload."
)
explicit_skill_target = _skill_target.target_path
p = explicit_skill_target if explicit_skill_target is not None else ctx.drive_path(write_path)
# Defense-in-depth: settings.json is owner-only. Use inode-aware matching
# for symlinks/hardlinks/case-insensitive APFS/NTFS, with a fallback for

View file

@ -92,3 +92,54 @@ def test_short_form_create_marks_new_skill_self_authored(tmp_path, monkeypatch):
payload = drive_root / "skills" / "external" / "fresh"
assert (payload / "SKILL.md").exists(), "the manifest must be created"
assert (payload / ".self_authored.json").exists(), "a newly created external skill must be marked self-authored"
def test_runtime_data_explicit_path_applies_manifest_first_typo_guard(tmp_path, monkeypatch):
"""The explicit runtime_data skills/<bucket>/<skill>/... write path applies the SAME manifest-first
typo guard as the bucket/skill_name short-form (SSOT is_skill_create_typo). Closes the
write_file(root=runtime_data, path=skills/external/new/plugin.py) bypass the cumulative review found:
a non-manifest into a MISSING external payload is a typo (error, nothing mkdir'd); the root manifest
of a NEW external skill creates; an EXISTING payload writes any filename; a missing marketplace
payload errors (install, don't author)."""
from ouroboros import config
from ouroboros.tools.registry import ToolContext, ToolRegistry
repo_dir = tmp_path / "repo"
drive_root = tmp_path / "drive"
repo_dir.mkdir()
drive_root.mkdir()
monkeypatch.setattr(config, "DATA_DIR", str(drive_root))
monkeypatch.setattr("ouroboros.safety.check_safety", lambda *a, **k: (True, ""))
ctx = ToolContext(repo_dir=repo_dir, drive_root=drive_root)
registry = ToolRegistry(repo_dir=repo_dir, drive_root=drive_root)
registry._ctx = ctx
def _write(path, content):
return registry.execute("write_file", {"root": "runtime_data", "path": path, "content": content})
# 1. TYPO: a non-manifest into a MISSING external payload is blocked; nothing is mkdir'd.
typo = _write("skills/external/newskill/plugin.py", "code")
assert "DATA_WRITE_ERROR" in typo and "skill payload not found" in typo, typo
assert not (drive_root / "skills" / "external" / "newskill").exists(), "a typo must not mkdir a payload"
# 2. CREATE: the root manifest of a NEW external skill is the authoring signal -> allowed.
created = _write("skills/external/newskill/SKILL.md", "---\nname: newskill\n---\nNew.\n")
assert "ERROR" not in created and "BLOCKED" not in created, created
assert (drive_root / "skills" / "external" / "newskill" / "SKILL.md").is_file()
# 3. EDIT: an EXISTING payload accepts any filename (the guard only fires for a MISSING payload).
edit = _write("skills/external/newskill/plugin.py", "code")
assert "ERROR" not in edit and "BLOCKED" not in edit, edit
assert (drive_root / "skills" / "external" / "newskill" / "plugin.py").is_file()
# 4. MARKETPLACE: a missing clawhub/ouroboroshub payload is installed, never authored -> error.
market = _write("skills/clawhub/fromhub/SKILL.md", "# hub")
assert "DATA_WRITE_ERROR" in market and "skill payload not found" in market, market
# 5. NORMALIZED-PREFIX BYPASS: a redundant drive-root prefix must NOT smuggle past the guard —
# _normalize_data_read_path strips it to skills/..., and the guard now runs on that normalized
# write_path (not the raw path), so the typo is still caught and nothing is mkdir'd.
drive_prefix = str(drive_root).lstrip("/")
sneaky = _write(f"{drive_prefix}/skills/external/sneaky/plugin.py", "code")
assert "DATA_WRITE_ERROR" in sneaky and "skill payload not found" in sneaky, sneaky
assert not (drive_root / "skills" / "external" / "sneaky").exists(), "normalized-prefix typo must not mkdir a payload"