mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Second rolling sync of the campaign tree with the live upstream branch (40 upstream commits since the previous merge-basef3fbfdbb). Automerge resolved 18 of the 25 files both sides touched; the seven textual conflicts and the six semantic ones the automerge hid were resolved by class: - generated: ouroboros/size_ratchet_manifest.py taken from upstream and regenerated on the merged tree (the upstream test tests/test_ui_smoke_project_continuity.py entered the 1001-1500 band; its upstream rationale is carried verbatim); domains, inventories regenerated (FROZEN_CONTRACTS_INVENTORY.md). - protected prose: prompts/SAFETY.md takes upstream's compressed shape; the retired OUROBOROS_SCOPE_REVIEW_FLOOR control (removed outright by v7next ABI-5, owner Q10=A) is dropped from the owner-only controls sentence and the protected-path mirror is regenerated from the merged runtime_mode_policy (36 paths + the contracts prefix). prompts/SYSTEM.md (rewritten upstream) gets the v7next safety-critical set (registry split organs, tool_result). Both mirrors are pinned by tests/test_packaging_sync.py. - tests: upstream's Windows/CRLF/argv fixes win over the campaign's interim shims (capability predicates argv lists, golden _posix fold, telegram parity pytest-timeout instead of signal.alarm, chat_plain_system_rows CRLF normaliser, quarantine test import). Upstream's new governance and terminal-receipt tests are adapted to the v7 contracts they meet on this tree: the module-level registry_guard_process._run_shell_safety_check and the typed ToolResult code SKILL_STATE_WRITE_BLOCKED (D02/D04), the shell_outputs home of _sensitive_output_component_reason, and the ABI-2 task-result stamp (unstamped fixture rows are quarantined by the reader, owner Q8=B) in tests/test_terminal_delegation_receipt.py. The git catalog schema pin in tests/test_git_extraction.py moves to the bytes produced by upstream's advisory_review -> preflight_review description rename (332a02f1); the catalog itself is unchanged. Gates on the merged tree: ruff F, check_domains, inventories --check, size_ratchet --check, v7next_adoption, git diff --check all rc 0; the targeted suites for every conflicted or both-sides-touched file and web node --test (847) pass. The full CI-shape battery follows as a separate evidence run.
323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""Effect/position predicates instead of text-mention denies (#447 WS2-d).
|
|
|
|
A2: skill owner-state mentions take the family writeish + read-carve;
|
|
A3: sudo is judged at command-head position, never as a data token;
|
|
A4: interpreter write inference proves the receiver / agrees across regex+AST;
|
|
A6: skill preflight permission findings prove the PluginAPI receiver or degrade;
|
|
A7: git read-only classification is one SSOT with mode parsers, gh is argv-parsed.
|
|
"""
|
|
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
from ouroboros.shell_parse import sudo_noninteractive_violation
|
|
from ouroboros.tools import registry_guard_process
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A3 — sudo head-walk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
["rg", "sudo", "README.md"],
|
|
["printf", "sudo"],
|
|
["grep", "-rn", "sudo", "ouroboros/"],
|
|
["ls", "/usr/bin/sudo"],
|
|
["grep", "-rn", "sudoedit", "docs/"],
|
|
["pytest", "tests/test_sudo_option_parser.py"],
|
|
["sudo", "-n", "true"],
|
|
["sudo", "--non-interactive", "true"],
|
|
])
|
|
def test_sudo_named_as_data_or_noninteractive_is_allowed(cmd):
|
|
assert sudo_noninteractive_violation(cmd) is False
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
["sudo", "true"],
|
|
["sudoedit", "/etc/hosts"],
|
|
["sudo", "-S", "true"],
|
|
["sudo", "-nS", "true"],
|
|
["sh", "-c", "sudo apt update"],
|
|
["nohup", "sudo", "apt", "install", "-y", "jq"],
|
|
"echo hi && sudo whoami",
|
|
["sudo", "-n", "sh", "-c", "sudo whoami"],
|
|
])
|
|
def test_sudo_at_command_head_is_still_guarded(cmd):
|
|
assert sudo_noninteractive_violation(cmd) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A2 — skill owner-state mention takes the read-carve
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _registry(tmp_path):
|
|
from ouroboros.tools.registry import ToolRegistry
|
|
|
|
repo = tmp_path / "repo"
|
|
drive = tmp_path / "data"
|
|
repo.mkdir(exist_ok=True)
|
|
drive.mkdir(exist_ok=True)
|
|
return ToolRegistry(repo_dir=repo, drive_root=drive)
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
"rg 'review.json' data/state/skills/",
|
|
"grep -rn enabled.json data/state/skills",
|
|
])
|
|
def test_skill_state_pure_read_inspection_is_allowed(tmp_path, cmd):
|
|
# The runtime_data file plane explicitly allows reading review.json; the
|
|
# shell plane must not refuse the same read with a WRITE-named marker.
|
|
blocked = registry_guard_process._run_shell_safety_check(_registry(tmp_path), {"cmd": cmd}, "advanced")
|
|
assert blocked is None
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
"rm data/state/skills/weather/review.json",
|
|
"cp payload.json data/state/skills/weather/grants.json",
|
|
'python -c "open(\'data/state/skills/w/enabled.json\', \'w\').write(\'{}\')"',
|
|
])
|
|
def test_skill_state_write_shapes_stay_blocked(tmp_path, cmd):
|
|
blocked = registry_guard_process._run_shell_safety_check(_registry(tmp_path), {"cmd": cmd}, "advanced")
|
|
# v7 D02: the guard returns a typed ToolResult; the code is the contract.
|
|
assert blocked is not None and blocked.code == "SKILL_STATE_WRITE_BLOCKED"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A4 — receiver proof in the AST walker + regex/AST agreement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _walk(code: str):
|
|
from ouroboros.tools.shell_guards import _python_write_targets_and_unknown
|
|
|
|
return _python_write_targets_and_unknown(textwrap.dedent(code))
|
|
|
|
|
|
def test_str_literal_receiver_replace_is_not_a_write():
|
|
targets, unknown = _walk("s = 'a,b'\nprint(s.replace(',', ';'))\n")
|
|
assert targets == [] and unknown is False
|
|
|
|
|
|
def test_list_literal_receiver_remove_is_not_a_write():
|
|
targets, unknown = _walk("xs = [1, 2]\nitem = 2\nxs.remove(item)\n")
|
|
assert targets == [] and unknown is False
|
|
|
|
|
|
def test_local_class_save_call_site_is_not_a_write():
|
|
code = """
|
|
class A:
|
|
def save(self):
|
|
return 1
|
|
A().save()
|
|
"""
|
|
targets, unknown = _walk(code)
|
|
assert targets == [] and unknown is False
|
|
|
|
|
|
def test_local_class_save_with_real_write_inside_is_still_seen():
|
|
code = """
|
|
class A:
|
|
def save(self):
|
|
open('out.txt', 'w').write('x')
|
|
A().save()
|
|
"""
|
|
targets, unknown = _walk(code)
|
|
assert "out.txt" in targets
|
|
|
|
|
|
def test_path_receiver_rename_is_still_a_write():
|
|
targets, _ = _walk("import pathlib\np = pathlib.Path('a.txt')\np.write_text('x')\n")
|
|
# Name bound via a Path(...) call is NOT carved as a str receiver.
|
|
assert "a.txt" in targets
|
|
|
|
|
|
def test_sqlite_readonly_uri_is_not_a_write():
|
|
targets, unknown = _walk(
|
|
"import sqlite3\nsqlite3.connect('file:/tmp/db.sqlite?mode=ro', uri=True)\n"
|
|
)
|
|
assert targets == [] and unknown is False
|
|
|
|
|
|
def test_sqlite_plain_connect_is_still_a_write():
|
|
targets, unknown = _walk("import sqlite3\nsqlite3.connect('db.sqlite')\n")
|
|
assert "db.sqlite" in targets or unknown
|
|
|
|
|
|
def test_regex_lane_agrees_with_ast_on_list_remove_and_ro_sqlite():
|
|
from ouroboros.tools.write_shape import interpreter_write_shape
|
|
|
|
assert interpreter_write_shape(["python", "-c", "xs=[1,2]; xs.remove(1)"]) is False
|
|
assert interpreter_write_shape(
|
|
["python", "-c", "import sqlite3; sqlite3.connect('file:/tmp/x?mode=ro', uri=True)"]
|
|
) is False
|
|
assert interpreter_write_shape(["python", "-c", "import os; os.remove('x')"]) is True
|
|
assert interpreter_write_shape(["python", "-c", "import os; os.rename('a','b')"]) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A7 — git mode parsers, one read-only SSOT, glued -C, gh argv parse
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
"git config --get remote.origin.url",
|
|
"git config --list",
|
|
"git config user.name",
|
|
"git stash list",
|
|
"git stash show",
|
|
"git worktree list",
|
|
"git notes list",
|
|
"git notes",
|
|
"git bisect log",
|
|
"git merge-base HEAD origin/main",
|
|
"git check-ignore -v build/",
|
|
"git name-rev HEAD",
|
|
])
|
|
def test_git_readonly_modes_are_classified_readonly(cmd):
|
|
from ouroboros.git_shell_policy import is_readonly_git_command
|
|
|
|
assert is_readonly_git_command(cmd) is True
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
"git config user.name somebody",
|
|
"git config --unset user.name",
|
|
"git config set user.name x",
|
|
"git config -f /tmp/other --list", # external-file reader: no exemption ride
|
|
"git stash",
|
|
"git stash pop",
|
|
"git worktree add ../w",
|
|
"git notes add -m x",
|
|
"git bisect reset",
|
|
])
|
|
def test_git_mutating_or_external_modes_are_not_readonly(cmd):
|
|
from ouroboros.git_shell_policy import is_readonly_git_command
|
|
|
|
assert is_readonly_git_command(cmd) is False
|
|
|
|
|
|
def test_pure_read_inspection_uses_the_same_git_ssot():
|
|
from ouroboros.tools.registry import _is_pure_read_inspection
|
|
|
|
assert _is_pure_read_inspection("git stash list") is True
|
|
assert _is_pure_read_inspection("git config --get remote.origin.url") is True
|
|
assert _is_pure_read_inspection("git config user.name x") is False
|
|
assert _is_pure_read_inspection("git log --output=/tmp/x") is False
|
|
|
|
|
|
def test_glued_dash_c_selects_the_same_base_as_split(tmp_path):
|
|
from ouroboros.git_shell_policy import external_workspace_git_violation
|
|
|
|
runtime = tmp_path / "runtime"
|
|
runtime.mkdir()
|
|
outside = tmp_path / "proj"
|
|
outside.mkdir()
|
|
# argv lists, not f-strings: the POSIX lexer eats the backslashes of a
|
|
# Windows path inside a shell STRING (3-OS CI matrix rule).
|
|
for spelling in (
|
|
["git", "-C", str(runtime), "commit", "-m", "x"],
|
|
["git", f"-C{runtime}", "commit", "-m", "x"],
|
|
):
|
|
assert external_workspace_git_violation(
|
|
spelling,
|
|
active_root=outside,
|
|
cwd=str(outside),
|
|
protected_roots=[runtime],
|
|
), spelling
|
|
for spelling in (
|
|
["git", "-C", str(outside), "commit", "-m", "x"],
|
|
["git", f"-C{outside}", "commit", "-m", "x"],
|
|
):
|
|
assert external_workspace_git_violation(
|
|
spelling,
|
|
active_root=outside,
|
|
cwd=str(runtime),
|
|
protected_roots=[runtime],
|
|
) == "", spelling
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
"rg 'gh auth' docs/",
|
|
"echo 'run gh auth login later'",
|
|
"gh auth status",
|
|
"gh auth token",
|
|
"gh pr list",
|
|
])
|
|
def test_gh_mentions_and_readonly_auth_are_allowed(cmd):
|
|
from ouroboros.git_shell_policy import gh_shell_block_reason
|
|
|
|
assert gh_shell_block_reason(cmd) == ""
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", [
|
|
"gh auth login",
|
|
"gh auth logout",
|
|
"gh auth refresh",
|
|
"gh auth setup-git",
|
|
"sh -c 'gh auth login'",
|
|
"gh repo create mine",
|
|
"gh repo delete mine",
|
|
])
|
|
def test_gh_mutating_verbs_at_head_are_blocked(cmd):
|
|
from ouroboros.git_shell_policy import gh_shell_block_reason
|
|
|
|
assert "SAFETY_VIOLATION" in gh_shell_block_reason(cmd)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A6 — skill preflight permission findings prove the receiver or degrade
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _permission_findings(tmp_path, plugin_code: str, permissions=()):
|
|
from ouroboros.contracts.skill_manifest import SkillManifest
|
|
from ouroboros.tools.skill_preflight import _plugin_permission_findings
|
|
|
|
skill_dir = tmp_path / "skill"
|
|
skill_dir.mkdir(exist_ok=True)
|
|
(skill_dir / "plugin.py").write_text(textwrap.dedent(plugin_code), encoding="utf-8")
|
|
manifest = SkillManifest(
|
|
name="x", description="d", version="0.1.0", type="extension",
|
|
entry="plugin.py", permissions=list(permissions),
|
|
)
|
|
return _plugin_permission_findings(skill_dir, manifest)
|
|
|
|
|
|
def test_api_receiver_call_without_permission_still_blocks(tmp_path):
|
|
findings = _permission_findings(tmp_path, """
|
|
def register(api):
|
|
api.get_settings(['KEY'])
|
|
""")
|
|
assert [f for f in findings if f["permission"] == "read_settings" and f["ok"] is False]
|
|
|
|
|
|
def test_api_alias_receiver_is_still_proven(tmp_path):
|
|
findings = _permission_findings(tmp_path, """
|
|
def register(api):
|
|
a = api
|
|
a.register_route('/x', None)
|
|
""")
|
|
assert [f for f in findings if f["permission"] == "route" and f["ok"] is False]
|
|
|
|
|
|
def test_foreign_receiver_degrades_to_note_not_block(tmp_path):
|
|
findings = _permission_findings(tmp_path, """
|
|
import otherlib
|
|
|
|
def register(api):
|
|
otherlib.OtherLibrary().get_settings()
|
|
""")
|
|
row = [f for f in findings if f["permission"] == "read_settings"]
|
|
assert row and row[0]["ok"] is True and row[0].get("degraded") is True
|
|
|
|
|
|
def test_declared_permission_stays_ok(tmp_path):
|
|
findings = _permission_findings(tmp_path, """
|
|
def register(api):
|
|
api.get_settings(['KEY'])
|
|
""", permissions=["read_settings"])
|
|
row = [f for f in findings if f["permission"] == "read_settings"]
|
|
assert row and row[0]["ok"] is True and "degraded" not in row[0]
|