mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-04 08:09:48 +00:00
Seed the combined Telegram bridge and Mini App as a native skill, add symmetric skill conflicts and managed repair routing, preserve the standalone PoC process boundary, and wire native delivery through Colab and packaged bootstrap.
1501 lines
54 KiB
Python
1501 lines
54 KiB
Python
"""Phase 3 regression tests for ``ouroboros.skill_loader``.
|
|
|
|
Covers discovery, content-hashing, enabled-state persistence, and review
|
|
state round-trip. No network, no real review calls — these tests stay
|
|
hermetic against ``tmp_path``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import pathlib
|
|
|
|
import pytest
|
|
|
|
from ouroboros.skill_loader import (
|
|
LoadedSkill,
|
|
SkillReviewState,
|
|
VALID_REVIEW_STATUSES,
|
|
compute_content_hash,
|
|
discover_skills,
|
|
enabled_skill_conflicts,
|
|
find_skill,
|
|
list_available_for_execution,
|
|
load_enabled,
|
|
load_review_state,
|
|
load_skill,
|
|
save_enabled,
|
|
save_review_state,
|
|
skill_conflict_status,
|
|
skill_review_gate,
|
|
skill_state_dir,
|
|
summarize_skills,
|
|
)
|
|
|
|
|
|
def _write_skill(
|
|
repo_root: pathlib.Path,
|
|
name: str,
|
|
*,
|
|
manifest: str,
|
|
scripts: dict[str, str] | None = None,
|
|
manifest_name: str = "SKILL.md",
|
|
) -> pathlib.Path:
|
|
skill_dir = repo_root / name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / manifest_name).write_text(manifest, encoding="utf-8")
|
|
if scripts:
|
|
(skill_dir / "scripts").mkdir(exist_ok=True)
|
|
for filename, body in scripts.items():
|
|
(skill_dir / "scripts" / filename).write_text(body, encoding="utf-8")
|
|
return skill_dir
|
|
|
|
|
|
def _valid_script_manifest(name: str = "weather") -> str:
|
|
return (
|
|
"---\n"
|
|
f"name: {name}\n"
|
|
"description: Check the weather.\n"
|
|
"version: 0.1.0\n"
|
|
"type: script\n"
|
|
"runtime: python3\n"
|
|
"timeout_sec: 30\n"
|
|
"permissions: [net]\n"
|
|
"scripts:\n"
|
|
" - name: fetch.py\n"
|
|
" description: Fetch current weather.\n"
|
|
"---\n"
|
|
"# Weather skill\n\nCall fetch.py with a city.\n"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovery + loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_discover_skills_returns_empty_when_data_plane_missing(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
assert discover_skills(drive_root, repo_path="") == []
|
|
# A missing path is also silently tolerated — same "no skills" signal.
|
|
assert discover_skills(drive_root, repo_path=str(tmp_path / "does-not-exist")) == []
|
|
|
|
|
|
def test_discover_skills_uses_data_plane_native_bucket(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
native_root = drive_root / "skills" / "native"
|
|
_write_skill(
|
|
native_root,
|
|
"weather",
|
|
manifest=_valid_script_manifest("weather"),
|
|
scripts={"fetch.py": "print('ok')\n"},
|
|
)
|
|
(native_root / "weather" / ".seed-origin").write_text("seeded_from=test\n", encoding="utf-8")
|
|
skills = discover_skills(drive_root, repo_path="")
|
|
names = {s.name for s in skills}
|
|
assert "weather" in names
|
|
|
|
|
|
@pytest.mark.parametrize("declaration_owner", ["telegram", "telegram-bridge"])
|
|
def test_enabled_skill_conflicts_are_symmetric_for_one_sided_declarations(
|
|
tmp_path, declaration_owner
|
|
):
|
|
drive_root = tmp_path / "drive"
|
|
native_root = drive_root / "skills" / "native"
|
|
for name, other in (("telegram", "telegram-bridge"), ("telegram-bridge", "telegram")):
|
|
conflicts = f"conflicts: [{other}]\n" if name == declaration_owner else ""
|
|
_write_skill(
|
|
native_root,
|
|
name,
|
|
manifest=(
|
|
"---\n"
|
|
f"name: {name}\n"
|
|
"description: Telegram fixture.\n"
|
|
"version: 1.0.0\n"
|
|
"type: instruction\n"
|
|
f"{conflicts}"
|
|
"---\n"
|
|
),
|
|
)
|
|
save_enabled(drive_root, "telegram-bridge", True)
|
|
|
|
skills = discover_skills(drive_root, repo_path="")
|
|
telegram = next(skill for skill in skills if skill.name == "telegram")
|
|
assert enabled_skill_conflicts(telegram, skills) == ["telegram-bridge"]
|
|
assert skill_conflict_status(telegram, skills) == {
|
|
"code": "skill_conflict",
|
|
"skills": ["telegram-bridge"],
|
|
"omitted": 0,
|
|
}
|
|
from ouroboros.skill_readiness import skill_readiness_for_execution
|
|
|
|
readiness = skill_readiness_for_execution(
|
|
drive_root,
|
|
telegram,
|
|
require_enabled=False,
|
|
require_grants=False,
|
|
skills=skills,
|
|
)
|
|
assert readiness.conflict["code"] == "skill_conflict"
|
|
assert any(item.startswith("skill_conflict:") for item in readiness.owner_action_blockers)
|
|
|
|
|
|
def test_missing_and_disabled_conflict_targets_are_inert(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
native_root = drive_root / "skills" / "native"
|
|
_write_skill(
|
|
native_root,
|
|
"telegram",
|
|
manifest=(
|
|
"---\nname: telegram\ndescription: Telegram\nversion: 1.0.0\n"
|
|
"type: instruction\nconflicts: [telegram-bridge]\n---\n"
|
|
),
|
|
)
|
|
skills = discover_skills(drive_root, repo_path="")
|
|
telegram = skills[0]
|
|
assert enabled_skill_conflicts(telegram, skills) == []
|
|
|
|
_write_skill(
|
|
native_root,
|
|
"telegram-bridge",
|
|
manifest=(
|
|
"---\nname: telegram-bridge\ndescription: Old\nversion: 1.0.0\n"
|
|
"type: instruction\n---\n"
|
|
),
|
|
)
|
|
skills = discover_skills(drive_root, repo_path="")
|
|
telegram = next(skill for skill in skills if skill.name == "telegram")
|
|
assert enabled_skill_conflicts(telegram, skills) == []
|
|
|
|
|
|
def test_load_skill_parses_manifest_and_computes_hash(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(
|
|
repo_root,
|
|
"weather",
|
|
manifest=_valid_script_manifest(),
|
|
scripts={"fetch.py": "print('hi')\n"},
|
|
)
|
|
loaded = load_skill(repo_root / "weather", drive_root)
|
|
assert isinstance(loaded, LoadedSkill)
|
|
assert loaded.name == "weather"
|
|
assert loaded.manifest.type == "script"
|
|
assert loaded.manifest.runtime == "python3"
|
|
assert loaded.content_hash # non-empty
|
|
assert loaded.enabled is False # default
|
|
assert loaded.review.status == "pending"
|
|
assert loaded.available_for_execution is False
|
|
|
|
|
|
def test_load_skill_returns_none_for_non_skill_dir(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
other = tmp_path / "random"
|
|
other.mkdir()
|
|
(other / "README.txt").write_text("hi", encoding="utf-8")
|
|
assert load_skill(other, drive_root) is None
|
|
|
|
|
|
def test_load_skill_surfaces_broken_manifest(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(
|
|
repo_root,
|
|
"broken",
|
|
manifest='{"name": ', # truncated JSON
|
|
manifest_name="skill.json",
|
|
)
|
|
loaded = load_skill(repo_root / "broken", drive_root)
|
|
assert loaded is not None
|
|
assert loaded.load_error
|
|
assert loaded.available_for_execution is False
|
|
|
|
|
|
def test_load_skill_surfaces_unreadable_manifest(tmp_path):
|
|
"""Phase 3 round 16 regression: an existing-but-unreadable manifest
|
|
must surface as ``load_error`` instead of silently looking like
|
|
"not a skill dir at all"."""
|
|
import platform
|
|
if platform.system() == "Windows":
|
|
pytest.skip("chmod-based permission test not portable to Windows")
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"unread",
|
|
manifest=_valid_script_manifest("unread"),
|
|
)
|
|
manifest_path = skill_dir / "SKILL.md"
|
|
original_mode = manifest_path.stat().st_mode
|
|
os.chmod(manifest_path, 0o000)
|
|
try:
|
|
loaded = load_skill(skill_dir, drive_root)
|
|
finally:
|
|
os.chmod(manifest_path, original_mode)
|
|
# Root users can read anything regardless of perms — skip the
|
|
# assertion in that case (rare, but CI runners vary).
|
|
if os.geteuid() == 0: # pragma: no cover — only hit in root CI
|
|
pytest.skip("root user bypasses 0o000 chmod, cannot trigger OSError")
|
|
assert loaded is not None, "Unreadable manifest must still appear in discovery."
|
|
assert loaded.load_error, "load_error should be populated for unreadable manifests."
|
|
assert "unreadable" in loaded.load_error.lower()
|
|
|
|
|
|
def test_discover_skills_picks_up_multiple(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(repo_root, "alpha", manifest=_valid_script_manifest("alpha"))
|
|
_write_skill(repo_root, "beta", manifest=_valid_script_manifest("beta"))
|
|
skills = discover_skills(drive_root, repo_path=str(repo_root))
|
|
names = {s.name for s in skills}
|
|
assert names == {"alpha", "beta"}
|
|
|
|
|
|
def test_find_skill_returns_match_and_missing(tmp_path, monkeypatch):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(repo_root, "alpha", manifest=_valid_script_manifest("alpha"))
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
assert find_skill(drive_root, "alpha") is not None
|
|
assert find_skill(drive_root, "does-not-exist") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Content hashing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_content_hash_changes_when_script_edited(tmp_path):
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"alpha",
|
|
manifest=_valid_script_manifest("alpha"),
|
|
scripts={"fetch.py": "print('one')\n"},
|
|
)
|
|
before = compute_content_hash(skill_dir)
|
|
(skill_dir / "scripts" / "fetch.py").write_text("print('two')\n", encoding="utf-8")
|
|
after = compute_content_hash(skill_dir)
|
|
assert before != after
|
|
|
|
|
|
def test_content_hash_stable_against_state_dir_noise(tmp_path):
|
|
"""State-dir writes must not invalidate the skill content hash."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"alpha",
|
|
manifest=_valid_script_manifest("alpha"),
|
|
scripts={"fetch.py": "print('x')\n"},
|
|
)
|
|
before = compute_content_hash(skill_dir)
|
|
# State-dir writes happen in ``data/state/skills/<name>/``, which is
|
|
# outside the skill directory entirely — hash should be unaffected.
|
|
save_enabled(drive_root, "alpha", True)
|
|
save_review_state(
|
|
drive_root,
|
|
"alpha",
|
|
SkillReviewState(status="pass", content_hash=before),
|
|
)
|
|
after = compute_content_hash(skill_dir)
|
|
assert before == after
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State persistence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_enabled_round_trip(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
assert load_enabled(drive_root, "x") is False
|
|
save_enabled(drive_root, "x", True)
|
|
assert load_enabled(drive_root, "x") is True
|
|
save_enabled(drive_root, "x", False)
|
|
assert load_enabled(drive_root, "x") is False
|
|
|
|
|
|
@pytest.mark.parametrize("payload,write_bytes", [
|
|
(json.dumps({"enabled": "false"}).encode("utf-8"), None), # non-boolean value
|
|
(b"{\"enabled\": \xff}", None), # non-UTF-8 bytes
|
|
])
|
|
def test_load_enabled_fails_closed_on_corrupt_state(payload, write_bytes, tmp_path):
|
|
"""load_enabled must default to False on any corrupt state file.
|
|
|
|
Parametrized in v5.15.x from test_load_enabled_fails_closed_on_non_boolean_payload
|
|
+ test_load_enabled_fails_closed_on_non_utf8_state_file."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
raw_path = skill_state_dir(drive_root, "x") / "enabled.json"
|
|
raw_path.write_bytes(payload)
|
|
assert load_enabled(drive_root, "x") is False
|
|
|
|
|
|
def test_review_state_round_trip(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
# Default when no file on disk.
|
|
assert load_review_state(drive_root, "x").status == "pending"
|
|
state = SkillReviewState(
|
|
status="pass",
|
|
content_hash="abcd",
|
|
findings=[{"item": "manifest_schema", "verdict": "PASS", "severity": "critical", "reason": "ok"}],
|
|
reviewer_models=["openai/gpt-5.5"],
|
|
timestamp="2026-04-21T00:00:00+00:00",
|
|
prompt_chars=1234,
|
|
cost_usd=0.5,
|
|
raw_actor_records=[{"model_id": "openai/gpt-5.5", "raw_text": "full"}],
|
|
)
|
|
save_review_state(drive_root, "x", state)
|
|
reloaded = load_review_state(drive_root, "x")
|
|
assert reloaded.status == "clean"
|
|
assert reloaded.content_hash == "abcd"
|
|
assert reloaded.reviewer_models == ["openai/gpt-5.5"]
|
|
assert reloaded.prompt_chars == 1234
|
|
assert reloaded.raw_actor_records == [{"model_id": "openai/gpt-5.5", "raw_text": "full"}]
|
|
|
|
raw = json.loads((skill_state_dir(drive_root, "x") / "review.json").read_text(encoding="utf-8"))
|
|
assert "status" not in raw
|
|
|
|
|
|
def test_load_review_state_live_aggregates_soft_findings(tmp_path, monkeypatch):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
state = SkillReviewState(
|
|
status="advisory",
|
|
content_hash="abcd",
|
|
findings=[{
|
|
"item": "timeout_and_output_discipline",
|
|
"verdict": "FAIL",
|
|
"severity": "advisory",
|
|
"reason": "soft",
|
|
}],
|
|
)
|
|
save_review_state(drive_root, "x", state)
|
|
|
|
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
|
assert load_review_state(drive_root, "x", skill_type="script").status == "warnings"
|
|
|
|
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
|
|
assert load_review_state(drive_root, "x", skill_type="script").status == "warnings"
|
|
|
|
|
|
def test_load_review_state_fails_closed_on_invalid_numeric_fields(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
raw_path = skill_state_dir(drive_root, "x") / "review.json"
|
|
raw_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"status": "pass",
|
|
"content_hash": "abcd",
|
|
"prompt_chars": "not-an-int",
|
|
"cost_usd": "not-a-float",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
reloaded = load_review_state(drive_root, "x")
|
|
assert reloaded.status == "clean"
|
|
assert reloaded.prompt_chars == 0
|
|
assert reloaded.cost_usd == 0.0
|
|
|
|
|
|
def test_load_review_state_fails_closed_on_non_utf8_state_file(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
raw_path = skill_state_dir(drive_root, "x") / "review.json"
|
|
raw_path.write_bytes(b"{\"status\": \"pass\", \"content_hash\": \xff}")
|
|
reloaded = load_review_state(drive_root, "x")
|
|
assert reloaded.status == "pending"
|
|
assert reloaded.content_hash == ""
|
|
|
|
|
|
def test_review_state_unknown_status_clamped_to_pending(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
raw_path = skill_state_dir(drive_root, "x") / "review.json"
|
|
raw_path.write_text(
|
|
json.dumps({"status": "TURBO", "content_hash": "abcd"}),
|
|
encoding="utf-8",
|
|
)
|
|
reloaded = load_review_state(drive_root, "x")
|
|
assert reloaded.status == "pending"
|
|
assert reloaded.content_hash == "abcd"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# available_for_execution gating
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_available_for_execution_requires_pass_review_and_enabled(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(
|
|
repo_root,
|
|
"alpha",
|
|
manifest=_valid_script_manifest("alpha"),
|
|
scripts={"fetch.py": "print('x')\n"},
|
|
)
|
|
# Step 1: pending + disabled → not available.
|
|
assert list_available_for_execution(drive_root, repo_path=str(repo_root)) == []
|
|
|
|
# Step 2: enabled but still pending → not available.
|
|
save_enabled(drive_root, "alpha", True)
|
|
assert list_available_for_execution(drive_root, repo_path=str(repo_root)) == []
|
|
|
|
# Step 3: pass review with the current hash → available.
|
|
loaded = find_skill(drive_root, "alpha", repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
save_review_state(
|
|
drive_root,
|
|
"alpha",
|
|
SkillReviewState(status="pass", content_hash=loaded.content_hash),
|
|
)
|
|
available = list_available_for_execution(drive_root, repo_path=str(repo_root))
|
|
assert [s.name for s in available] == ["alpha"]
|
|
|
|
# Step 4: edit the script → review goes stale → not available again.
|
|
(loaded.skill_dir / "scripts" / "fetch.py").write_text("print('edited')\n", encoding="utf-8")
|
|
available = list_available_for_execution(drive_root, repo_path=str(repo_root))
|
|
assert available == []
|
|
|
|
|
|
def test_available_for_execution_rejects_unsupported_runtime(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(
|
|
repo_root,
|
|
"alpha",
|
|
manifest=_valid_script_manifest("alpha").replace("runtime: python3", "runtime: perl"),
|
|
scripts={"fetch.py": "print('x')\n"},
|
|
)
|
|
save_enabled(drive_root, "alpha", True)
|
|
loaded = find_skill(drive_root, "alpha", repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
save_review_state(
|
|
drive_root,
|
|
"alpha",
|
|
SkillReviewState(status="pass", content_hash=loaded.content_hash),
|
|
)
|
|
refreshed = find_skill(drive_root, "alpha", repo_path=str(repo_root))
|
|
assert refreshed is not None
|
|
assert refreshed.available_for_execution is False
|
|
|
|
|
|
def test_extension_skill_never_executable_in_phase3(tmp_path):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
manifest = (
|
|
"---\n"
|
|
"name: ext1\n"
|
|
"type: extension\n"
|
|
"version: 0.1.0\n"
|
|
"entry: plugin.py\n"
|
|
"permissions: [widget]\n"
|
|
"---\n"
|
|
"body\n"
|
|
)
|
|
skill_dir = _write_skill(repo_root, "ext1", manifest=manifest)
|
|
(skill_dir / "plugin.py").write_text("def register(api): pass\n", encoding="utf-8")
|
|
save_enabled(drive_root, "ext1", True)
|
|
loaded = find_skill(drive_root, "ext1", repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
save_review_state(
|
|
drive_root,
|
|
"ext1",
|
|
SkillReviewState(status="pass", content_hash=loaded.content_hash),
|
|
)
|
|
loaded = find_skill(drive_root, "ext1", repo_path=str(repo_root))
|
|
assert loaded.manifest.is_extension()
|
|
assert loaded.available_for_execution is False, (
|
|
"Phase 3 must defer type=extension execution until Phase 4."
|
|
)
|
|
|
|
|
|
def test_loaded_skill_identity_is_directory_basename_not_manifest_name(tmp_path):
|
|
"""Phase 3 round 9 regression: tool schemas advertise ``skill`` as
|
|
the directory name in ``OUROBOROS_SKILLS_REPO_PATH``. ``LoadedSkill.name``
|
|
+ the durable state dir key MUST match that so ``skill_exec("weather")``
|
|
resolves ``skills/weather/`` regardless of ``manifest.name`` free-form
|
|
content (``Weather Skill``, localised label, etc.).
|
|
"""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
weird_manifest = (
|
|
"---\n"
|
|
"name: Weather Skill Display\n"
|
|
"description: Check the weather.\n"
|
|
"version: 0.1.0\n"
|
|
"type: script\n"
|
|
"runtime: python3\n"
|
|
"timeout_sec: 30\n"
|
|
"scripts:\n"
|
|
" - name: fetch.py\n"
|
|
"---\n"
|
|
"body\n"
|
|
)
|
|
_write_skill(
|
|
repo_root,
|
|
"weather",
|
|
manifest=weird_manifest,
|
|
scripts={"fetch.py": "print('ok')\n"},
|
|
)
|
|
loaded = find_skill(drive_root, "weather", repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
assert loaded.name == "weather"
|
|
# Manifest display name preserved as metadata.
|
|
assert loaded.manifest.name == "Weather Skill Display"
|
|
# Addressable by directory name, NOT by the sanitised manifest name.
|
|
from ouroboros.skill_loader import _sanitize_skill_name as _sn
|
|
assert _sn("Weather Skill Display") != loaded.name
|
|
|
|
|
|
def test_hidden_helper_files_are_hashed_and_reviewed(tmp_path):
|
|
"""Phase 3 round 10 regression: a blanket "skip all dotfiles" rule
|
|
would let a hand-rolled ``.hidden_helper.py`` be imported by a
|
|
reviewed script without contributing to the content hash. Hidden
|
|
files OTHER than VCS/cache metadata must be hashed + reviewed."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"sneak",
|
|
manifest=_valid_script_manifest("sneak"),
|
|
scripts={"main.py": "import importlib\nimportlib.import_module('.hidden_helper')\n"},
|
|
)
|
|
(skill_dir / ".hidden_helper.py").write_text("X = 1\n", encoding="utf-8")
|
|
before = compute_content_hash(skill_dir, manifest_scripts=[{"name": "main.py"}])
|
|
(skill_dir / ".hidden_helper.py").write_text("X = 'poisoned'\n", encoding="utf-8")
|
|
after = compute_content_hash(skill_dir, manifest_scripts=[{"name": "main.py"}])
|
|
assert before != after, (
|
|
"Hidden helper file must be hashed — the subprocess can still "
|
|
"import it, so a review PASS must stale when it changes."
|
|
)
|
|
|
|
|
|
def test_vcs_cache_dirs_are_not_hashed(tmp_path):
|
|
"""Conversely, ``.git``/``__pycache__``/editor scratch directories
|
|
MUST be excluded from the hash so a byte-flip in a cache file does
|
|
not invalidate the review."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"cacheskill",
|
|
manifest=_valid_script_manifest("cacheskill"),
|
|
scripts={"main.py": "print('ok')\n"},
|
|
)
|
|
(skill_dir / ".git").mkdir()
|
|
(skill_dir / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
|
(skill_dir / "__pycache__").mkdir()
|
|
(skill_dir / "__pycache__" / "main.cpython-311.pyc").write_bytes(b"\x00\x01")
|
|
before = compute_content_hash(skill_dir, manifest_scripts=[{"name": "main.py"}])
|
|
(skill_dir / ".git" / "HEAD").write_text("ref: refs/heads/other\n", encoding="utf-8")
|
|
(skill_dir / "__pycache__" / "main.cpython-311.pyc").write_bytes(b"\x02\x03")
|
|
after = compute_content_hash(skill_dir, manifest_scripts=[{"name": "main.py"}])
|
|
assert before == after, "VCS/cache scratch must be excluded from the hash."
|
|
|
|
|
|
def test_symlink_escape_excluded_from_pack(tmp_path):
|
|
"""Phase 3 round 10 regression: a symlink inside ``skill_dir`` whose
|
|
target resolves outside the tree must NOT be hashed — otherwise
|
|
``compute_content_hash`` + ``_build_skill_file_packs`` would exfiltrate
|
|
arbitrary local file contents to external reviewer models."""
|
|
import platform
|
|
if platform.system() == "Windows":
|
|
pytest.skip("symlink creation requires admin on Windows")
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"lnk",
|
|
manifest=_valid_script_manifest("lnk"),
|
|
scripts={"main.py": "print('ok')\n"},
|
|
)
|
|
outside = tmp_path / "outside_secret.txt"
|
|
outside.write_text("SECRET_PAYLOAD\n", encoding="utf-8")
|
|
escape_link = skill_dir / "escape.txt"
|
|
os.symlink(outside, escape_link)
|
|
_iter_payload_files_list = None
|
|
# Use the private walker directly — this is the "would the hash /
|
|
# review pack see this file" question.
|
|
from ouroboros.skill_loader import _iter_payload_files
|
|
reviewed = _iter_payload_files(skill_dir, manifest_scripts=[{"name": "main.py"}])
|
|
assert escape_link.resolve() not in {p.resolve() for p in reviewed}
|
|
# Hash is still deterministic (covers in-tree files only).
|
|
assert compute_content_hash(skill_dir, manifest_scripts=[{"name": "main.py"}])
|
|
|
|
|
|
def test_sensitive_files_fail_closed_on_load(tmp_path):
|
|
"""Phase 3 round 20: a skill that ships a sensitive-shape file
|
|
(`.env`, `credentials.json`, `.pem`, ...) fails to load. Rationale:
|
|
silently excluding the file from hash/review would let a reviewed
|
|
skill ``open('.env').read()`` at runtime to exfiltrate credentials
|
|
that the reviewer never saw. The loader fails closed via
|
|
``SkillPayloadUnreadable``; the user must rename / relocate the
|
|
file out of the skill directory."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"secrety",
|
|
manifest=_valid_script_manifest("secrety"),
|
|
scripts={"main.py": "print('ok')\n"},
|
|
)
|
|
(skill_dir / ".env").write_text("SECRET_KEY=leak\n", encoding="utf-8")
|
|
from ouroboros.skill_loader import SkillPayloadUnreadable
|
|
with pytest.raises(SkillPayloadUnreadable):
|
|
compute_content_hash(skill_dir, manifest_scripts=[{"name": "main.py"}])
|
|
# The LoadedSkill reflects the load_error rather than crashing.
|
|
loaded = load_skill(skill_dir, drive_root)
|
|
assert loaded is not None
|
|
assert loaded.load_error
|
|
assert "sensitive" in loaded.load_error.lower()
|
|
assert loaded.available_for_execution is False
|
|
|
|
|
|
def test_sanitized_name_collision_surfaces_as_load_error(tmp_path):
|
|
"""Phase 3 round 12 regression: ``skills/hello world/`` and
|
|
``skills/hello_world/`` both sanitise to the same identity. The
|
|
loader must refuse to merge their state and surface a load_error
|
|
on each collision member."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(
|
|
repo_root,
|
|
"hello world",
|
|
manifest=_valid_script_manifest("hello world"),
|
|
)
|
|
_write_skill(
|
|
repo_root,
|
|
"hello_world",
|
|
manifest=_valid_script_manifest("hello_world"),
|
|
)
|
|
skills = discover_skills(drive_root, repo_path=str(repo_root))
|
|
assert len(skills) == 2
|
|
for s in skills:
|
|
assert s.load_error
|
|
assert "name collision" in s.load_error.lower()
|
|
assert s.available_for_execution is False
|
|
|
|
|
|
def test_toplevel_skill_files_are_hashed_and_reviewed(tmp_path):
|
|
"""Phase 3 round 8 regression: runtime surface == reviewed surface.
|
|
|
|
A subprocess started with ``cwd=skill_dir`` can ``import`` any
|
|
non-hidden file at the top level. If those files were not part of
|
|
``_iter_payload_files`` the PASS verdict would not stale when
|
|
they change. This test drops a top-level ``helper.py`` and checks
|
|
that it IS included in the content hash."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"mixed",
|
|
manifest=_valid_script_manifest("mixed"),
|
|
scripts={"fetch.py": "from helper import X\nprint(X)\n"},
|
|
)
|
|
(skill_dir / "helper.py").write_text("X = 'v1'\n", encoding="utf-8")
|
|
before = compute_content_hash(
|
|
skill_dir,
|
|
manifest_entry="",
|
|
manifest_scripts=[{"name": "fetch.py"}],
|
|
)
|
|
(skill_dir / "helper.py").write_text("X = 'v2-poisoned'\n", encoding="utf-8")
|
|
after = compute_content_hash(
|
|
skill_dir,
|
|
manifest_entry="",
|
|
manifest_scripts=[{"name": "fetch.py"}],
|
|
)
|
|
assert before != after, (
|
|
"Editing a top-level helper.py must invalidate the content hash — "
|
|
"skill_exec runs with cwd=skill_dir so that file is reachable."
|
|
)
|
|
|
|
|
|
def test_extension_status_reflects_persisted_verdict_in_phase4(tmp_path, monkeypatch):
|
|
"""Phase 4 lifted the old Phase 3 ``pending_phase4`` overlay — now
|
|
that the extension loader exists, a persisted review verdict for a
|
|
``type: extension`` skill must surface verbatim so operators and
|
|
the Skills UI see the real state."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
manifest = (
|
|
"---\n"
|
|
"name: ext2\n"
|
|
"type: extension\n"
|
|
"version: 0.1.0\n"
|
|
"entry: plugin.py\n"
|
|
"permissions: [widget]\n"
|
|
"---\n"
|
|
"body\n"
|
|
)
|
|
skill_dir = _write_skill(repo_root, "ext2", manifest=manifest)
|
|
(skill_dir / "plugin.py").write_text("def register(api): pass\n", encoding="utf-8")
|
|
|
|
loaded_initial = find_skill(drive_root, "ext2", repo_path=str(repo_root))
|
|
assert loaded_initial is not None
|
|
save_review_state(
|
|
drive_root,
|
|
"ext2",
|
|
SkillReviewState(status="pass", content_hash=loaded_initial.content_hash),
|
|
)
|
|
|
|
reloaded = find_skill(drive_root, "ext2", repo_path=str(repo_root))
|
|
assert reloaded is not None
|
|
# Real verdict surfaces — Phase 4 retired the ``pending_phase4`` overlay.
|
|
assert reloaded.review.status == "clean"
|
|
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
summary = summarize_skills(drive_root)
|
|
statuses = {s["name"]: s["review_status"] for s in summary["skills"]}
|
|
assert statuses["ext2"] == "clean"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# summarize_skills shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_summarize_skills_shape_contains_counts_and_flat_list(tmp_path, monkeypatch):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(repo_root, "alpha", manifest=_valid_script_manifest("alpha"))
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
summary = summarize_skills(drive_root)
|
|
assert summary["count"] == 1
|
|
assert summary["available"] == 0
|
|
assert summary["pending_review"] == 1
|
|
assert summary["blocker_review"] == 0
|
|
assert summary["warning_review"] == 0
|
|
assert summary["broken"] == 0
|
|
assert [s["name"] for s in summary["skills"]] == ["alpha"]
|
|
|
|
|
|
def test_summarize_skills_reflects_runtime_mode_light(tmp_path, monkeypatch):
|
|
"""v5.1.2 Frame A: a reviewed + enabled skill stays ``available``
|
|
in light mode, because ``skill_exec`` no longer refuses light.
|
|
The static-readiness signal and the available-for-execution flag
|
|
converge in this release."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
_write_skill(
|
|
repo_root,
|
|
"alpha",
|
|
manifest=_valid_script_manifest("alpha"),
|
|
scripts={"fetch.py": "print('ok')\n"},
|
|
)
|
|
# Mark reviewed + enabled so the skill would be statically available.
|
|
loaded = find_skill(drive_root, "alpha", repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
save_enabled(drive_root, "alpha", True)
|
|
save_review_state(
|
|
drive_root,
|
|
"alpha",
|
|
SkillReviewState(status="pass", content_hash=loaded.content_hash),
|
|
)
|
|
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
|
|
# advanced → available
|
|
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
|
adv = summarize_skills(drive_root)
|
|
assert adv["available"] == 1
|
|
assert adv["skills"][0]["available_for_execution"] is True
|
|
|
|
# v5.1.2 Frame A: light is also ``available`` — skills run regardless
|
|
# of runtime_mode (light still blocks repo self-modification +
|
|
# elevation ratchet, just not skill execution).
|
|
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "light")
|
|
light = summarize_skills(drive_root)
|
|
assert light["available"] == 1
|
|
assert light["skills"][0]["available_for_execution"] is True
|
|
assert light["skills"][0]["review_gate"]["executable_review"] is True
|
|
assert light["skills"][0]["executable_review"] is True
|
|
assert light["skills"][0]["static_ready"] is True
|
|
|
|
|
|
def test_summarize_skills_blocks_missing_isolated_deps(tmp_path, monkeypatch):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
manifest = _valid_script_manifest("alpha").replace(
|
|
"scripts:\n",
|
|
"install_specs:\n"
|
|
" - kind: pip\n"
|
|
" package: wheel\n"
|
|
"scripts:\n",
|
|
)
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"alpha",
|
|
manifest=manifest,
|
|
scripts={"fetch.py": "print('ok')\n"},
|
|
)
|
|
loaded = find_skill(drive_root, "alpha", repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
save_enabled(drive_root, "alpha", True)
|
|
save_review_state(
|
|
drive_root,
|
|
"alpha",
|
|
SkillReviewState(status="pass", content_hash=compute_content_hash(skill_dir)),
|
|
)
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
|
|
summary = summarize_skills(drive_root)
|
|
|
|
assert list_available_for_execution(drive_root, repo_path=str(repo_root)) == []
|
|
assert summary["available"] == 0
|
|
assert summary["skills"][0]["available_for_execution"] is False
|
|
assert summary["skills"][0]["static_ready"] is False
|
|
|
|
|
|
def test_available_summary_keeps_runtime_and_script_substrate_gate(tmp_path, monkeypatch):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
unsupported_runtime = _valid_script_manifest("bad_runtime").replace(
|
|
"runtime: python3\n",
|
|
"runtime: perl\n",
|
|
)
|
|
missing_script = _valid_script_manifest("missing_script")
|
|
skill_dirs = {
|
|
"bad_runtime": _write_skill(
|
|
repo_root,
|
|
"bad_runtime",
|
|
manifest=unsupported_runtime,
|
|
scripts={"fetch.py": "print('ok')\n"},
|
|
),
|
|
"missing_script": _write_skill(
|
|
repo_root,
|
|
"missing_script",
|
|
manifest=missing_script,
|
|
scripts={},
|
|
),
|
|
}
|
|
for name, skill_dir in skill_dirs.items():
|
|
save_enabled(drive_root, name, True)
|
|
save_review_state(
|
|
drive_root,
|
|
name,
|
|
SkillReviewState(status="pass", content_hash=compute_content_hash(skill_dir)),
|
|
)
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
|
|
summary = summarize_skills(drive_root)
|
|
|
|
assert list_available_for_execution(drive_root, repo_path=str(repo_root)) == []
|
|
assert summary["available"] == 0
|
|
by_name = {row["name"]: row for row in summary["skills"]}
|
|
assert by_name["bad_runtime"]["available_for_execution"] is False
|
|
assert by_name["bad_runtime"]["static_ready"] is False
|
|
assert by_name["missing_script"]["available_for_execution"] is False
|
|
assert by_name["missing_script"]["static_ready"] is False
|
|
|
|
|
|
def test_valid_review_statuses_exported():
|
|
assert "clean" in VALID_REVIEW_STATUSES
|
|
assert "warnings" in VALID_REVIEW_STATUSES
|
|
assert "blockers" in VALID_REVIEW_STATUSES
|
|
# Legacy persisted names remain accepted for migration.
|
|
assert "pass" in VALID_REVIEW_STATUSES
|
|
assert "pending" in VALID_REVIEW_STATUSES
|
|
assert "pending_phase4" in VALID_REVIEW_STATUSES
|
|
|
|
|
|
def test_skill_review_gate_allows_warnings_under_blocking(monkeypatch):
|
|
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
|
|
|
gate = skill_review_gate("warnings", stale=False)
|
|
|
|
assert gate["executable_review"] is True
|
|
assert gate["blocking_reason"] == "warnings_do_not_block_execution"
|
|
assert gate["review_enforcement"] == "blocking"
|
|
|
|
|
|
def test_skill_review_gate_allows_legacy_advisory_pass(monkeypatch):
|
|
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
|
|
|
|
gate = skill_review_gate("advisory_pass", stale=False)
|
|
|
|
assert gate["executable_review"] is True
|
|
|
|
|
|
def test_skill_review_gate_revalidates_advisory_pass_under_blocking(monkeypatch):
|
|
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
|
|
|
gate = skill_review_gate("advisory_pass", stale=False)
|
|
|
|
assert gate["executable_review"] is True
|
|
assert gate["blocking_reason"] == "warnings_do_not_block_execution"
|
|
|
|
|
|
def test_warnings_available_under_blocking(tmp_path, monkeypatch):
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = _write_skill(
|
|
repo_root,
|
|
"alpha",
|
|
manifest=_valid_script_manifest("alpha"),
|
|
scripts={"fetch.py": "print('ok')\n"},
|
|
)
|
|
loaded = find_skill(drive_root, "alpha", repo_path=str(repo_root))
|
|
assert loaded is not None
|
|
save_enabled(drive_root, "alpha", True)
|
|
save_review_state(
|
|
drive_root,
|
|
"alpha",
|
|
SkillReviewState(status="advisory_pass", content_hash=compute_content_hash(skill_dir)),
|
|
)
|
|
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
|
monkeypatch.setenv("OUROBOROS_SKILLS_REPO_PATH", str(repo_root))
|
|
|
|
summary = summarize_skills(drive_root)
|
|
|
|
assert len(list_available_for_execution(drive_root, repo_path=str(repo_root))) == 1
|
|
assert summary["available"] == 1
|
|
assert summary["skills"][0]["available_for_execution"] is True
|
|
assert summary["skills"][0]["static_ready"] is True
|
|
assert summary["skills"][0]["review_gate"]["blocking_reason"] == "warnings_do_not_block_execution"
|
|
|
|
|
|
def test_skill_grants_are_content_and_request_bound(tmp_path):
|
|
from ouroboros.contracts.skill_manifest import SkillManifest
|
|
from ouroboros.skill_loader import (
|
|
LoadedSkill,
|
|
SkillReviewState,
|
|
grant_status_for_skill,
|
|
save_skill_grants,
|
|
)
|
|
|
|
drive_root = tmp_path / "drive"
|
|
skill_dir = tmp_path / "skill"
|
|
drive_root.mkdir()
|
|
skill_dir.mkdir()
|
|
manifest = SkillManifest(
|
|
name="granty",
|
|
description="grant test",
|
|
version="0.1",
|
|
type="script",
|
|
env_from_settings=["OPENROUTER_API_KEY"],
|
|
)
|
|
skill = LoadedSkill(
|
|
name="granty",
|
|
skill_dir=skill_dir,
|
|
manifest=manifest,
|
|
content_hash="hash-a",
|
|
review=SkillReviewState(status="pass", content_hash="hash-a"),
|
|
)
|
|
save_skill_grants(
|
|
drive_root,
|
|
"granty",
|
|
["OPENROUTER_API_KEY", "GITHUB_TOKEN"],
|
|
content_hash="hash-a",
|
|
requested_keys=["OPENROUTER_API_KEY"],
|
|
)
|
|
status = grant_status_for_skill(drive_root, skill)
|
|
assert status["granted_keys"] == ["OPENROUTER_API_KEY"]
|
|
assert status["all_granted"] is True
|
|
skill.content_hash = "hash-b"
|
|
stale = grant_status_for_skill(drive_root, skill)
|
|
assert stale["granted_keys"] == []
|
|
assert stale["missing_keys"] == ["OPENROUTER_API_KEY"]
|
|
|
|
skill.content_hash = "hash-a"
|
|
skill.source = "clawhub"
|
|
unsupported = grant_status_for_skill(drive_root, skill)
|
|
assert unsupported["unsupported_for_skill_type"] is False
|
|
assert unsupported["usable"] is True
|
|
assert unsupported["granted_keys"] == ["OPENROUTER_API_KEY"]
|
|
|
|
|
|
def test_grant_status_supports_extension_skills(tmp_path):
|
|
"""v5.2.2 dual-track grants: ``type: extension`` skills are now
|
|
eligible for owner core-key grants alongside ``type: script``."""
|
|
from ouroboros.contracts.skill_manifest import SkillManifest
|
|
from ouroboros.skill_loader import (
|
|
LoadedSkill,
|
|
SkillReviewState,
|
|
grant_status_for_skill,
|
|
save_skill_grants,
|
|
)
|
|
|
|
drive_root = tmp_path / "drive"
|
|
skill_dir = tmp_path / "ext"
|
|
drive_root.mkdir()
|
|
skill_dir.mkdir()
|
|
manifest = SkillManifest(
|
|
name="ext_grant",
|
|
description="extension grant test",
|
|
version="0.1",
|
|
type="extension",
|
|
env_from_settings=["OPENROUTER_API_KEY"],
|
|
permissions=["read_settings"],
|
|
)
|
|
skill = LoadedSkill(
|
|
name="ext_grant",
|
|
skill_dir=skill_dir,
|
|
manifest=manifest,
|
|
content_hash="ext-hash",
|
|
review=SkillReviewState(status="pass", content_hash="ext-hash"),
|
|
)
|
|
no_grant = grant_status_for_skill(drive_root, skill)
|
|
assert no_grant["unsupported_for_skill_type"] is False
|
|
assert no_grant["all_granted"] is False
|
|
assert no_grant["missing_keys"] == ["OPENROUTER_API_KEY"]
|
|
|
|
save_skill_grants(
|
|
drive_root,
|
|
"ext_grant",
|
|
["OPENROUTER_API_KEY"],
|
|
content_hash="ext-hash",
|
|
requested_keys=["OPENROUTER_API_KEY"],
|
|
)
|
|
granted = grant_status_for_skill(drive_root, skill)
|
|
assert granted["unsupported_for_skill_type"] is False
|
|
assert granted["all_granted"] is True
|
|
assert granted["usable"] is True
|
|
assert granted["granted_keys"] == ["OPENROUTER_API_KEY"]
|
|
|
|
|
|
def test_grant_status_supports_privileged_permissions(tmp_path):
|
|
from ouroboros.contracts.skill_manifest import SkillManifest
|
|
from ouroboros.skill_loader import (
|
|
LoadedSkill,
|
|
SkillReviewState,
|
|
grant_status_for_skill,
|
|
save_skill_grants,
|
|
)
|
|
|
|
drive_root = tmp_path / "drive"
|
|
skill_dir = tmp_path / "ext"
|
|
drive_root.mkdir()
|
|
skill_dir.mkdir()
|
|
manifest = SkillManifest(
|
|
name="injector",
|
|
description="inject grant test",
|
|
version="0.1",
|
|
type="extension",
|
|
permissions=["inject_chat", "subscribe_event"],
|
|
subscribe_events=["chat.outbound"],
|
|
)
|
|
skill = LoadedSkill(
|
|
name="injector",
|
|
skill_dir=skill_dir,
|
|
manifest=manifest,
|
|
content_hash="inject-hash",
|
|
review=SkillReviewState(status="pass", content_hash="inject-hash"),
|
|
)
|
|
|
|
missing = grant_status_for_skill(drive_root, skill)
|
|
assert missing["missing_permissions"] == ["inject_chat", "subscribe_event:chat.outbound"]
|
|
assert missing["usable"] is False
|
|
|
|
save_skill_grants(
|
|
drive_root,
|
|
"injector",
|
|
[],
|
|
content_hash="inject-hash",
|
|
requested_keys=[],
|
|
granted_permissions=["inject_chat", "subscribe_event:chat.outbound"],
|
|
requested_permissions=["inject_chat", "subscribe_event:chat.outbound"],
|
|
)
|
|
granted = grant_status_for_skill(drive_root, skill)
|
|
assert granted["all_granted"] is True
|
|
assert granted["usable"] is True
|
|
assert granted["granted_permissions"] == ["inject_chat", "subscribe_event:chat.outbound"]
|
|
|
|
|
|
def test_auto_grant_if_enabled_returns_outcome_with_requested_even_when_off(tmp_path, monkeypatch):
|
|
import ouroboros.config as config
|
|
from ouroboros.contracts.skill_manifest import SkillManifest
|
|
from ouroboros.skill_loader import (
|
|
LoadedSkill,
|
|
SkillReviewState,
|
|
auto_grant_if_enabled,
|
|
load_skill_grants,
|
|
)
|
|
|
|
monkeypatch.setattr(config, "SETTINGS_PATH", tmp_path / "missing-settings.json")
|
|
monkeypatch.setenv("OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS", "false")
|
|
drive_root = tmp_path / "drive"
|
|
skill_dir = tmp_path / "skill"
|
|
drive_root.mkdir()
|
|
skill_dir.mkdir()
|
|
skill = LoadedSkill(
|
|
name="auto",
|
|
skill_dir=skill_dir,
|
|
manifest=SkillManifest(
|
|
name="auto",
|
|
description="auto grant test",
|
|
version="0.1",
|
|
type="extension",
|
|
env_from_settings=["OPENROUTER_API_KEY"],
|
|
permissions=["inject_chat"],
|
|
),
|
|
content_hash="hash-a",
|
|
review=SkillReviewState(status="pass", content_hash="hash-a"),
|
|
)
|
|
|
|
outcome = auto_grant_if_enabled(drive_root, skill)
|
|
|
|
assert outcome.granted is False
|
|
assert outcome.requested_keys == ["OPENROUTER_API_KEY"]
|
|
assert outcome.requested_permissions == ["inject_chat"]
|
|
assert outcome.granted_keys == []
|
|
assert outcome.granted_permissions == []
|
|
assert load_skill_grants(drive_root, "auto")["granted_keys"] == []
|
|
|
|
|
|
def test_auto_grant_if_enabled_marks_granted_when_toggle_on(tmp_path, monkeypatch):
|
|
import ouroboros.config as config
|
|
from ouroboros.contracts.skill_manifest import SkillManifest
|
|
from ouroboros.skill_loader import (
|
|
LoadedSkill,
|
|
SkillReviewState,
|
|
auto_grant_if_enabled,
|
|
load_skill_grants,
|
|
)
|
|
|
|
monkeypatch.setattr(config, "SETTINGS_PATH", tmp_path / "missing-settings.json")
|
|
monkeypatch.setenv("OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS", "true")
|
|
drive_root = tmp_path / "drive"
|
|
skill_dir = tmp_path / "skill"
|
|
drive_root.mkdir()
|
|
skill_dir.mkdir()
|
|
skill = LoadedSkill(
|
|
name="auto",
|
|
skill_dir=skill_dir,
|
|
manifest=SkillManifest(
|
|
name="auto",
|
|
description="auto grant test",
|
|
version="0.1",
|
|
type="extension",
|
|
env_from_settings=["OPENROUTER_API_KEY"],
|
|
permissions=["inject_chat"],
|
|
),
|
|
content_hash="hash-a",
|
|
review=SkillReviewState(status="pass", content_hash="hash-a"),
|
|
)
|
|
|
|
outcome = auto_grant_if_enabled(drive_root, skill)
|
|
|
|
assert outcome.granted is True
|
|
assert outcome.requested_keys == ["OPENROUTER_API_KEY"]
|
|
assert outcome.granted_keys == ["OPENROUTER_API_KEY"]
|
|
assert outcome.requested_permissions == ["inject_chat"]
|
|
assert outcome.granted_permissions == ["inject_chat"]
|
|
grants = load_skill_grants(drive_root, "auto")
|
|
assert grants["granted_keys"] == ["OPENROUTER_API_KEY"]
|
|
assert grants["granted_permissions"] == ["inject_chat"]
|
|
|
|
|
|
def test_auto_grant_if_enabled_uses_executable_review_gate(tmp_path, monkeypatch):
|
|
import ouroboros.config as config
|
|
from ouroboros.contracts.skill_manifest import SkillManifest
|
|
from ouroboros.skill_loader import (
|
|
LoadedSkill,
|
|
SkillReviewState,
|
|
auto_grant_if_enabled,
|
|
load_skill_grants,
|
|
)
|
|
|
|
monkeypatch.setattr(config, "SETTINGS_PATH", tmp_path / "missing-settings.json")
|
|
monkeypatch.setenv("OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS", "true")
|
|
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
|
drive_root = tmp_path / "drive"
|
|
skill_dir = tmp_path / "skill"
|
|
drive_root.mkdir()
|
|
skill_dir.mkdir()
|
|
skill = LoadedSkill(
|
|
name="auto_blocked",
|
|
skill_dir=skill_dir,
|
|
manifest=SkillManifest(
|
|
name="auto_blocked",
|
|
description="auto grant blocker test",
|
|
version="0.1",
|
|
type="extension",
|
|
env_from_settings=["OPENROUTER_API_KEY"],
|
|
),
|
|
content_hash="hash-a",
|
|
review=SkillReviewState(status="blockers", content_hash="hash-a"),
|
|
)
|
|
|
|
outcome = auto_grant_if_enabled(drive_root, skill)
|
|
|
|
assert outcome.granted is False
|
|
assert outcome.requested_keys == ["OPENROUTER_API_KEY"]
|
|
assert outcome.granted_keys == []
|
|
assert load_skill_grants(drive_root, "auto_blocked")["granted_keys"] == []
|
|
|
|
|
|
def test_save_skill_grants_merges_partial_approvals(tmp_path):
|
|
"""A subsequent partial-key grant must not silently revoke
|
|
previously-approved keys. The merge is bound to the same
|
|
content_hash + requested_keys; any change to either resets the
|
|
persisted state because the owner has not consented to the new
|
|
shape yet."""
|
|
from ouroboros.skill_loader import (
|
|
load_skill_grants,
|
|
save_skill_grants,
|
|
)
|
|
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
|
|
save_skill_grants(
|
|
drive_root,
|
|
"merge_demo",
|
|
["OPENROUTER_API_KEY"],
|
|
content_hash="hash-x",
|
|
requested_keys=["OPENROUTER_API_KEY", "GITHUB_TOKEN"],
|
|
)
|
|
save_skill_grants(
|
|
drive_root,
|
|
"merge_demo",
|
|
["GITHUB_TOKEN"],
|
|
content_hash="hash-x",
|
|
requested_keys=["OPENROUTER_API_KEY", "GITHUB_TOKEN"],
|
|
)
|
|
after_merge = load_skill_grants(drive_root, "merge_demo")
|
|
assert sorted(after_merge["granted_keys"]) == ["GITHUB_TOKEN", "OPENROUTER_API_KEY"]
|
|
|
|
# New content hash invalidates the previous persisted state.
|
|
save_skill_grants(
|
|
drive_root,
|
|
"merge_demo",
|
|
["OPENROUTER_API_KEY"],
|
|
content_hash="hash-y",
|
|
requested_keys=["OPENROUTER_API_KEY", "GITHUB_TOKEN"],
|
|
)
|
|
after_rotate = load_skill_grants(drive_root, "merge_demo")
|
|
assert after_rotate["content_hash"] == "hash-y"
|
|
assert after_rotate["granted_keys"] == ["OPENROUTER_API_KEY"]
|
|
|
|
|
|
def test_grant_status_unsupported_for_instruction_skills(tmp_path):
|
|
"""Instruction-type skills cannot receive core grants — they have
|
|
no executable surface, so a grant would be meaningless."""
|
|
from ouroboros.contracts.skill_manifest import SkillManifest
|
|
from ouroboros.skill_loader import (
|
|
LoadedSkill,
|
|
SkillReviewState,
|
|
grant_status_for_skill,
|
|
)
|
|
|
|
drive_root = tmp_path / "drive"
|
|
skill_dir = tmp_path / "instr"
|
|
drive_root.mkdir()
|
|
skill_dir.mkdir()
|
|
manifest = SkillManifest(
|
|
name="instr_grant",
|
|
description="instruction grant test",
|
|
version="0.1",
|
|
type="instruction",
|
|
env_from_settings=["OPENROUTER_API_KEY"],
|
|
)
|
|
skill = LoadedSkill(
|
|
name="instr_grant",
|
|
skill_dir=skill_dir,
|
|
manifest=manifest,
|
|
content_hash="instr-hash",
|
|
review=SkillReviewState(status="pass", content_hash="instr-hash"),
|
|
)
|
|
status = grant_status_for_skill(drive_root, skill)
|
|
assert status["unsupported_for_skill_type"] is True
|
|
assert status["all_granted"] is False
|
|
assert status["usable"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Safety: skill name sanitization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_skill_state_dir_resists_path_escape(tmp_path):
|
|
"""A malicious manifest ``name: ../../etc`` cannot escape the state root."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
malicious = "../../etc/passwd"
|
|
state_path = skill_state_dir(drive_root, malicious)
|
|
resolved = state_path.resolve()
|
|
state_root_resolved = (drive_root / "state" / "skills").resolve()
|
|
# The returned path must stay under data/state/skills/.
|
|
assert resolved.is_relative_to(state_root_resolved)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hidden-directory filter: relative-parts only, not absolute parts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_payload_hash_works_in_hidden_parent_dir(tmp_path):
|
|
"""Regression: ``_iter_payload_files`` used to drop every payload when
|
|
the skills checkout lived in a hidden parent directory (e.g.
|
|
``~/.skills``) because it checked absolute ``path.parts`` for
|
|
dotfile components."""
|
|
# Build the skill inside a hidden parent so the resolved absolute
|
|
# path of each payload file contains a ``.xyz`` component.
|
|
hidden_root = tmp_path / ".xyz"
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
skill_dir = hidden_root / "weather"
|
|
(skill_dir / "scripts").mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(_valid_script_manifest(), encoding="utf-8")
|
|
(skill_dir / "scripts" / "fetch.py").write_text("print('hi')\n", encoding="utf-8")
|
|
|
|
hashed = compute_content_hash(skill_dir)
|
|
# Hash must cover the script, not just the manifest.
|
|
loaded = load_skill(skill_dir, drive_root)
|
|
assert loaded is not None
|
|
assert loaded.content_hash == hashed
|
|
assert hashed != compute_content_hash(skill_dir.parent / "does-not-exist")
|
|
|
|
(skill_dir / "scripts" / "fetch.py").write_text("print('edited')\n", encoding="utf-8")
|
|
assert compute_content_hash(skill_dir) != hashed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manifest entry file is part of the hash (extension-type skills)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_manifest_entry_file_is_hashed_and_invalidates_review(tmp_path):
|
|
"""A ``type: extension`` skill's ``entry`` file (e.g. ``plugin.py``)
|
|
must be part of the content hash so editing it staleness-invalidates
|
|
the review. This is the Phase 3 round 2 regression for
|
|
``_iter_payload_files``."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
manifest = (
|
|
"---\n"
|
|
"name: ext1\n"
|
|
"type: extension\n"
|
|
"version: 0.1.0\n"
|
|
"entry: plugin.py\n"
|
|
"permissions: [widget]\n"
|
|
"---\n"
|
|
"body\n"
|
|
)
|
|
skill_dir = repo_root / "ext1"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(manifest, encoding="utf-8")
|
|
(skill_dir / "plugin.py").write_text("def register(api): pass # v1\n", encoding="utf-8")
|
|
|
|
loaded = load_skill(skill_dir, drive_root)
|
|
assert loaded is not None
|
|
before = loaded.content_hash
|
|
|
|
# Edit plugin.py — this must change the hash because the manifest
|
|
# declared it as the entry file.
|
|
(skill_dir / "plugin.py").write_text("def register(api): pass # v2\n", encoding="utf-8")
|
|
after = compute_content_hash(skill_dir, manifest_entry="plugin.py")
|
|
assert before != after, (
|
|
"Editing the manifest-declared entry file must invalidate the "
|
|
"skill content hash so the review goes stale."
|
|
)
|
|
|
|
|
|
def test_manifest_scripts_outside_scripts_dir_are_hashed(tmp_path):
|
|
"""Phase 3 round 6 regression: a manifest ``scripts[].name`` that points
|
|
outside the conventional ``scripts/`` directory (e.g. ``bin/run.sh``)
|
|
must be included in the content hash.
|
|
|
|
Before this fix ``skill_exec`` would still execute the declared file,
|
|
but ``compute_content_hash`` ignored it — editing that file would
|
|
NOT stale-invalidate the review, so a malicious skill could ship a
|
|
reviewed manifest and then mutate the actual runnable file.
|
|
"""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = repo_root / "weird"
|
|
(skill_dir / "bin").mkdir(parents=True)
|
|
(skill_dir / "bin" / "run.sh").write_text("#!/bin/sh\necho 'v1'\n", encoding="utf-8")
|
|
(skill_dir / "SKILL.md").write_text(
|
|
(
|
|
"---\n"
|
|
"name: weird\n"
|
|
"description: Runs a non-scripts/ script.\n"
|
|
"version: 0.1.0\n"
|
|
"type: script\n"
|
|
"runtime: bash\n"
|
|
"timeout_sec: 5\n"
|
|
"scripts:\n"
|
|
" - name: bin/run.sh\n"
|
|
" description: The actual runnable.\n"
|
|
"---\n"
|
|
"body\n"
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
loaded = load_skill(skill_dir, drive_root)
|
|
assert loaded is not None
|
|
before = loaded.content_hash
|
|
(skill_dir / "bin" / "run.sh").write_text("#!/bin/sh\necho 'v2'\n", encoding="utf-8")
|
|
after = compute_content_hash(
|
|
skill_dir,
|
|
manifest_entry=loaded.manifest.entry,
|
|
manifest_scripts=loaded.manifest.scripts,
|
|
)
|
|
assert before != after, (
|
|
"Editing a manifest-declared script outside scripts/ must "
|
|
"invalidate the skill content hash so the review goes stale."
|
|
)
|
|
|
|
|
|
def test_manifest_entry_outside_skill_dir_is_rejected(tmp_path):
|
|
"""A malicious manifest ``entry: ../../etc/passwd`` must not cause
|
|
the hasher to follow the absolute path."""
|
|
drive_root = tmp_path / "drive"
|
|
drive_root.mkdir()
|
|
repo_root = tmp_path / "skills"
|
|
skill_dir = repo_root / "ext1"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
(
|
|
"---\n"
|
|
"name: ext1\n"
|
|
"type: extension\n"
|
|
"version: 0.1.0\n"
|
|
"entry: ../../etc/passwd\n"
|
|
"permissions: [widget]\n"
|
|
"---\n"
|
|
"body\n"
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
loaded = load_skill(skill_dir, drive_root)
|
|
# The loader must still succeed (parse error would be a separate
|
|
# finding) but ``compute_content_hash`` must ignore the escape path.
|
|
assert loaded is not None
|
|
# Hash is non-empty (manifest counts) but does not include
|
|
# /etc/passwd content.
|
|
assert loaded.content_hash
|