mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Preserve Cyber skill resources in hashes and review descriptors
NOT_REVIEWED local checkpoint. Include sensitive-named resources in the existing Cyber payload hash and represent native-magic resources through the existing binary descriptor. Preserve ordinary refusal policy and real read/manifest failures. No binary execution support or provider calls are added.
This commit is contained in:
parent
b3d118a8b5
commit
98512ca32e
3 changed files with 138 additions and 8 deletions
|
|
@ -319,11 +319,13 @@ def _iter_payload_files(
|
|||
"""Return files hashed for review freshness.
|
||||
|
||||
The hash covers every regular runtime-reachable file under ``skill_dir``
|
||||
except metadata/cache/sensitive paths, lifecycle control files
|
||||
except metadata/cache paths, lifecycle control files
|
||||
(``HASH_EXEMPT_CONTROL_FILENAMES``), and symlink escapes. Manifest entry
|
||||
points are re-added only when confined, keeping executable and reviewed
|
||||
surfaces aligned. ``include_control_files=True`` reproduces the legacy
|
||||
pre-v6.31 hash (control files included) for one-shot state migration.
|
||||
Sensitive-looking filenames refuse ordinary loading; Cyber includes them
|
||||
in the same byte hash and review pack rather than silently omitting them.
|
||||
"""
|
||||
out: List[pathlib.Path] = []
|
||||
resolved_root = skill_dir.resolve()
|
||||
|
|
@ -354,6 +356,10 @@ def _iter_payload_files(
|
|||
_SENSITIVE_EXTENSIONS,
|
||||
_SENSITIVE_NAMES,
|
||||
)
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
cyber = runtime_mode_at_least(get_runtime_mode(), "cyber_pro")
|
||||
|
||||
def _is_sensitive(path: pathlib.Path) -> bool:
|
||||
lowered = path.name.lower()
|
||||
|
|
@ -385,7 +391,7 @@ def _iter_payload_files(
|
|||
and resolved_root.parent.name == "native"
|
||||
):
|
||||
continue
|
||||
if _is_sensitive(path):
|
||||
if _is_sensitive(path) and not cyber:
|
||||
# Fail closed: a reviewed skill could still read a skipped
|
||||
# credential-shaped file at runtime.
|
||||
raise SkillPayloadUnreadable(
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ def _read_skill_file(
|
|||
path: pathlib.Path, *, relpath: str = ""
|
||||
) -> tuple[Optional[str], bytes, Optional[Dict[str, Any]]]:
|
||||
"""Read one skill file: ``(text, sha256_digest, descriptor)`` — exactly one set.
|
||||
Loadable executables (CONTENT magic bytes, never filename) hard-block review;
|
||||
WebAssembly (``WASM_MAGIC``, even when its bytes decode as UTF-8) and other
|
||||
non-UTF-8 files yield a typed descriptor instead of raw bytes."""
|
||||
Native executable magic blocks ordinary review; Cyber retains these bytes
|
||||
through a descriptor. Binary formats never become text merely because they
|
||||
decode as UTF-8. Unreadable source remains an actual read failure."""
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as exc:
|
||||
|
|
@ -98,11 +98,18 @@ def _read_skill_file(
|
|||
text = None
|
||||
kind = executable_magic_kind(data, is_utf8_text=text is not None)
|
||||
if kind:
|
||||
raise _SkillBinaryPayload(rel, len(data), kind)
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
if not runtime_mode_at_least(get_runtime_mode(), "cyber_pro"):
|
||||
raise _SkillBinaryPayload(rel, len(data), kind)
|
||||
digest = hashlib.sha256(data).digest()
|
||||
if text is not None and not data.startswith(WASM_MAGIC):
|
||||
if text is not None and not kind and not data.startswith(WASM_MAGIC):
|
||||
return text, digest, None
|
||||
return None, digest, binary_file_descriptor(rel, data, filename=path.name)
|
||||
descriptor = binary_file_descriptor(rel, data, filename=path.name)
|
||||
if kind:
|
||||
descriptor["format_from_magic"] = kind
|
||||
return None, digest, descriptor
|
||||
|
||||
|
||||
def _build_skill_file_packs(
|
||||
|
|
|
|||
117
tests/test_skill_cyber_payload.py
Normal file
117
tests/test_skill_cyber_payload.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Review projection does not deny Cyber the bytes of an inert skill resource."""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from ouroboros import config
|
||||
from ouroboros.skill_loader import SkillPayloadUnreadable, compute_content_hash, load_skill
|
||||
from ouroboros.skill_review_packs import _SkillBinaryPayload, _SkillFileUnreadable, _build_skill_file_packs, _read_skill_file
|
||||
|
||||
|
||||
@pytest.fixture(params=["pro", "cyber_pro"])
|
||||
def access(request, monkeypatch):
|
||||
config.reset_runtime_mode_baseline_for_tests()
|
||||
config.initialize_runtime_mode_baseline(request.param)
|
||||
# Caller-controlled env cannot elevate the effective boot mode.
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "cyber_pro")
|
||||
yield request.param
|
||||
config.reset_runtime_mode_baseline_for_tests()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [".env", "prod.env", "credentials.json", "id_rsa"])
|
||||
def test_sensitive_named_inert_resource_is_hashed_and_read_in_cyber(tmp_path, access, name):
|
||||
skill = tmp_path / "demo"
|
||||
skill.mkdir()
|
||||
manifest = b'{"name":"demo","description":"Resource fixture","type":"instruction"}'
|
||||
payload = b"INERT_TEST_RESOURCE=first\n"
|
||||
(skill / "skill.json").write_bytes(manifest)
|
||||
resource = skill / name
|
||||
resource.write_bytes(payload)
|
||||
if access == "pro":
|
||||
with pytest.raises(SkillPayloadUnreadable, match="credential filename"):
|
||||
compute_content_hash(skill)
|
||||
return
|
||||
expected = hashlib.sha256()
|
||||
for path in sorted(skill.iterdir()):
|
||||
expected.update(path.name.encode() + b"\0" + hashlib.sha256(path.read_bytes()).digest())
|
||||
content_hash = compute_content_hash(skill)
|
||||
assert content_hash == expected.hexdigest()
|
||||
pack = "\n".join(_build_skill_file_packs(skill, expected_content_hash=content_hash))
|
||||
assert f"### {name}\n" in pack and payload.decode() in pack
|
||||
assert not load_skill(skill, tmp_path / "data").load_error
|
||||
resource.write_bytes(b"INERT_TEST_RESOURCE=second\n")
|
||||
assert compute_content_hash(skill) != content_hash
|
||||
with pytest.raises(_SkillFileUnreadable, match="changed after hashing"):
|
||||
_build_skill_file_packs(skill, expected_content_hash=content_hash)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
b"\x7fELF" + b"\0" * 16, # Valid UTF-8 bytes still represent a binary-format fixture.
|
||||
b"MZ\x90\0" + b"\xff" * 16,
|
||||
b"\xcf\xfa\xed\xfe" + b"\0" * 16,
|
||||
importlib.util.MAGIC_NUMBER + b"\0" * 16,
|
||||
])
|
||||
def test_native_magic_resource_has_exact_descriptor_not_decoded_text(tmp_path, access, payload):
|
||||
resource = tmp_path / "notes.txt" # The suffix does not determine binary content.
|
||||
resource.write_bytes(payload)
|
||||
content_hash = compute_content_hash(tmp_path) # Native bytes were already hashable.
|
||||
if access == "pro":
|
||||
with pytest.raises(_SkillBinaryPayload):
|
||||
_read_skill_file(resource)
|
||||
return
|
||||
text, digest, descriptor = _read_skill_file(resource)
|
||||
assert text is None and digest == hashlib.sha256(payload).digest()
|
||||
assert descriptor["path"] == "notes.txt" and descriptor["size"] == len(payload)
|
||||
assert descriptor["sha256"] == hashlib.sha256(payload).hexdigest()
|
||||
assert descriptor["format_from_magic"]
|
||||
pack = "\n".join(_build_skill_file_packs(tmp_path, expected_content_hash=content_hash))
|
||||
assert "descriptor only, content not inlined" in pack
|
||||
assert descriptor["sha256"] in pack and "\0" not in pack
|
||||
resource.write_bytes(payload + b"\0")
|
||||
assert compute_content_hash(tmp_path) != content_hash
|
||||
|
||||
|
||||
def test_binary_content_and_sensitive_name_share_one_cyber_hash_surface(tmp_path, access):
|
||||
resource = tmp_path / "credentials.json"
|
||||
payload = b"\x7fELF" + b"\0" * 16
|
||||
resource.write_bytes(payload)
|
||||
if access == "pro":
|
||||
with pytest.raises(SkillPayloadUnreadable):
|
||||
compute_content_hash(tmp_path)
|
||||
return
|
||||
content_hash = compute_content_hash(tmp_path)
|
||||
pack = "\n".join(_build_skill_file_packs(tmp_path, expected_content_hash=content_hash))
|
||||
assert hashlib.sha256(payload).hexdigest() in pack
|
||||
assert "credentials.json (binary file" in pack
|
||||
|
||||
|
||||
def test_real_unreadable_source_stays_an_error(tmp_path, access, monkeypatch):
|
||||
resource = tmp_path / "resource.dat"
|
||||
resource.write_bytes(b"real bytes")
|
||||
original = pathlib.Path.read_bytes
|
||||
|
||||
def unavailable(path):
|
||||
if path == resource:
|
||||
raise OSError("fixture read failed")
|
||||
return original(path)
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "read_bytes", unavailable)
|
||||
with pytest.raises(_SkillFileUnreadable, match="fixture read failed"):
|
||||
_read_skill_file(resource)
|
||||
|
||||
|
||||
def test_binary_manifest_is_not_fabricated_as_supported_text(tmp_path, access):
|
||||
(tmp_path / "SKILL.md").write_bytes(b"\xff\xfe\x80opaque manifest")
|
||||
loaded = load_skill(tmp_path, tmp_path / "state-root")
|
||||
assert loaded is not None and loaded.load_error
|
||||
assert "UnicodeDecodeError" in loaded.load_error
|
||||
assert not loaded.content_hash
|
||||
assert not loaded.available_for_execution
|
||||
# Unsupported manifest parsing does not prevent raw resource inspection.
|
||||
text, digest, descriptor = _read_skill_file(tmp_path / "SKILL.md")
|
||||
assert text is None and descriptor["sha256"] == digest.hex()
|
||||
assert json.loads(json.dumps(descriptor))["path"] == "SKILL.md"
|
||||
Loading…
Add table
Add a link
Reference in a new issue