mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-05 00:29:46 +00:00
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:
parent
ae3b40ffd5
commit
61ccaff9d5
3 changed files with 96 additions and 4 deletions
|
|
@ -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}. "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue