mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 12:18:39 +00:00
363 lines
18 KiB
Python
363 lines
18 KiB
Python
"""What a local read-only subagent may reach.
|
|
|
|
Split verbatim out of ``tests/test_tool_capabilities.py`` by theme. This
|
|
module owns the read-only subagent profile boundary: forbidden tools at
|
|
execute time, the enabled extension tool it may still call, the
|
|
allowed-resources block on web/external tools, and the secret-file,
|
|
task-drive and skill-payload filters on its data and repo reads.
|
|
"""
|
|
import os
|
|
import pathlib
|
|
|
|
|
|
def test_local_readonly_subagent_execute_blocks_forbidden_tools(tmp_path, monkeypatch):
|
|
from ouroboros.contracts.task_constraint import TaskConstraint
|
|
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
|
import ouroboros.mcp_client as mcp_client
|
|
|
|
registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path)
|
|
registry.set_context(
|
|
ToolContext(
|
|
repo_dir=tmp_path,
|
|
drive_root=tmp_path,
|
|
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
|
)
|
|
)
|
|
|
|
assert registry.get_schema_by_name("write_file") is None
|
|
assert registry.get_schema_by_name("enable_tools") is None
|
|
assert registry.get_schema_by_name("schedule_subagent") is not None
|
|
# switch_model changes COGNITIVE POWER, not authority: a child that started cheap and
|
|
# found the work harder raises its own strength, and nothing about its sandbox moves.
|
|
# It was on the blocked list until v6.87.7 purely because power and authority were
|
|
# conflated; a read-only child stays read-only at any model.
|
|
assert registry.get_schema_by_name("switch_model") is not None
|
|
assert "LOCAL_READONLY_SUBAGENT_BLOCKED" not in registry.execute("switch_model", {})
|
|
monkeypatch.setattr(mcp_client, "ensure_configured_from_settings", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("MCP touched")))
|
|
assert "LOCAL_READONLY_SUBAGENT_BLOCKED" not in registry.execute("list_files", {"path": "."})
|
|
assert registry.get_schema_by_name("vcs_status") is not None
|
|
assert "TOOL_ACCESS_BLOCKED" not in registry.execute("vcs_status", {"root": "system_repo"})
|
|
blocked_tools = [
|
|
"write_file",
|
|
"edit_text",
|
|
"knowledge_write",
|
|
"update_scratchpad",
|
|
"update_identity",
|
|
"commit_reviewed",
|
|
"preflight_review",
|
|
"task_acceptance_review",
|
|
"skill_review",
|
|
"request_restart",
|
|
"enable_tools",
|
|
"run_command",
|
|
"skill_exec",
|
|
"list_skills",
|
|
]
|
|
for name in blocked_tools:
|
|
assert registry.get_schema_by_name(name) is None
|
|
assert "LOCAL_READONLY_SUBAGENT_BLOCKED" in registry.execute(name, {})
|
|
|
|
|
|
def test_local_readonly_subagent_allows_enabled_extension_tool(tmp_path, monkeypatch):
|
|
from ouroboros import extension_loader
|
|
from ouroboros.contracts.task_constraint import TaskConstraint
|
|
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
|
from tests._shared import clean_extension_runtime_state
|
|
from tests.test_extension_loader import _mark_isolated_deps_installed, _prepare_extension
|
|
|
|
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
|
clean_extension_runtime_state()
|
|
plugin = (
|
|
"def _lookup(ctx, query=''):\n"
|
|
" return 'external-ok:' + query\n"
|
|
"def register(api):\n"
|
|
" api.register_tool('lookup', _lookup, description='External lookup', "
|
|
"schema={'type': 'object', 'properties': {'query': {'type': 'string'}}}, timeout_sec=5)\n"
|
|
)
|
|
loaded, skills_repo, parent_drive = _prepare_extension(
|
|
tmp_path,
|
|
"research",
|
|
plugin,
|
|
permissions=["tool"],
|
|
extra_frontmatter="dependencies:\n - dummy_pkg\n",
|
|
)
|
|
_mark_isolated_deps_installed(parent_drive, loaded)
|
|
child_drive = tmp_path / "child-drive"
|
|
child_drive.mkdir()
|
|
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=parent_drive)
|
|
assert err is None, err
|
|
tool_name = extension_loader.extension_surface_name("research", "lookup")
|
|
assert extension_loader.is_extension_live("research", parent_drive, repo_path=str(skills_repo))
|
|
assert not extension_loader.is_extension_live("research", child_drive, repo_path=str(skills_repo))
|
|
assert extension_loader.get_tool(tool_name)["out_of_process"] is True
|
|
repo_dir = pathlib.Path(__file__).resolve().parents[1]
|
|
registry = ToolRegistry(repo_dir=repo_dir, drive_root=child_drive)
|
|
try:
|
|
registry.set_context(
|
|
ToolContext(
|
|
repo_dir=repo_dir,
|
|
drive_root=child_drive,
|
|
task_metadata={"budget_drive_root": str(parent_drive)},
|
|
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
|
)
|
|
)
|
|
assert registry.get_schema_by_name(tool_name) is not None
|
|
assert "external-ok:budget-root" in registry.execute(tool_name, {"query": "budget-root"})
|
|
finally:
|
|
clean_extension_runtime_state()
|
|
|
|
|
|
def test_allowed_resources_block_web_and_external_tools(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
|
|
from ouroboros import extension_loader
|
|
from ouroboros.contracts.task_contract import build_task_contract
|
|
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
|
|
|
registry = ToolRegistry(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
|
task_contract = build_task_contract({
|
|
"id": "task-resources",
|
|
"allowed_resources": {"web": "false", "network": "false"},
|
|
})
|
|
tool_name = extension_loader.extension_surface_name("research", "lookup")
|
|
with extension_loader._lock:
|
|
extension_loader._tools[tool_name] = {
|
|
"name": tool_name,
|
|
"handler": lambda ctx, **kwargs: "external-ok",
|
|
"description": "External lookup",
|
|
"schema": {"type": "object", "properties": {}},
|
|
"timeout_sec": 5,
|
|
"skill": "research",
|
|
}
|
|
monkeypatch.setattr(extension_loader, "is_extension_live", lambda *_a, **_k: True)
|
|
try:
|
|
registry.set_context(
|
|
ToolContext(
|
|
repo_dir=tmp_path / "repo",
|
|
drive_root=tmp_path / "data",
|
|
task_contract=task_contract,
|
|
task_metadata={"task_contract": task_contract},
|
|
)
|
|
)
|
|
assert task_contract["allowed_resources"] == {"web": False, "network": False}
|
|
assert "RESOURCE_CONSTRAINT_BLOCKED" in registry.execute("web_search", {"query": "x"})
|
|
# VLM tools are first-class vision tools, not web egress. Benchmark isolation
|
|
# withholds them by name via disabled_tools instead of relying on web=false.
|
|
assert "RESOURCE_CONSTRAINT_BLOCKED" not in registry.execute("vlm_query", {"prompt": "x"})
|
|
assert "RESOURCE_CONSTRAINT_BLOCKED" in registry.execute(
|
|
"vlm_query", {"prompt": "x", "image_url": "https://example.com/a.png"}
|
|
)
|
|
assert registry.get_schema_by_name(tool_name) is None
|
|
assert tool_name not in {schema["function"]["name"] for schema in registry.schemas()}
|
|
assert any(item.get("surface") == "extensions" and item.get("reason") == "resource_blocked" for item in registry.capability_omissions())
|
|
blocked = registry.execute(tool_name, {})
|
|
assert "RESOURCE_CONSTRAINT_BLOCKED" in blocked
|
|
assert "network=false" in blocked
|
|
|
|
alias_contract = build_task_contract({
|
|
"id": "task-resource-aliases",
|
|
"allowed_resources": {"allow_network": "false"},
|
|
})
|
|
registry.set_context(
|
|
ToolContext(
|
|
repo_dir=tmp_path / "repo",
|
|
drive_root=tmp_path / "data",
|
|
task_contract=alias_contract,
|
|
task_metadata={"task_contract": alias_contract},
|
|
)
|
|
)
|
|
assert alias_contract["allowed_resources"] == {"allow_network": False}
|
|
assert "RESOURCE_CONSTRAINT_BLOCKED" in registry.execute("web_search", {"query": "x"})
|
|
finally:
|
|
with extension_loader._lock:
|
|
extension_loader._tools.pop(tool_name, None)
|
|
|
|
|
|
def test_local_readonly_subagent_data_read_denies_secret_files(tmp_path):
|
|
from ouroboros.contracts.task_constraint import TaskConstraint
|
|
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
|
|
|
(tmp_path / "settings.json").write_text('{"OPENROUTER_API_KEY":"secret"}', encoding="utf-8")
|
|
(tmp_path / "settings.tmp").write_text('{"OPENROUTER_API_KEY":"secret"}', encoding="utf-8")
|
|
(tmp_path / ".settings.json.tmp.123").write_text('{"OPENROUTER_API_KEY":"secret"}', encoding="utf-8")
|
|
(tmp_path / ".env.local").write_text("TOKEN=secret", encoding="utf-8")
|
|
(tmp_path / "prod.env").write_text("TOKEN=secret", encoding="utf-8")
|
|
(tmp_path / "state" / "skills" / "weather").mkdir(parents=True)
|
|
(tmp_path / "state" / "skills" / "weather" / "grants.json").write_text("{}", encoding="utf-8")
|
|
(tmp_path / "state" / "skills" / "weather" / ".grants.json.tmp.123").write_text("{}", encoding="utf-8")
|
|
(tmp_path / "state" / "skills" / "weather" / "review.json.lock").write_text("{}", encoding="utf-8")
|
|
(tmp_path / "logs").mkdir()
|
|
(tmp_path / "logs" / "events.jsonl").write_text("{}", encoding="utf-8")
|
|
try:
|
|
os.symlink("settings.json", tmp_path / "alias.txt")
|
|
except (OSError, NotImplementedError):
|
|
pass
|
|
try:
|
|
os.link(tmp_path / "settings.json", tmp_path / "hardlink.txt")
|
|
except (OSError, NotImplementedError):
|
|
pass
|
|
|
|
registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path)
|
|
registry.set_context(
|
|
ToolContext(
|
|
repo_dir=tmp_path,
|
|
drive_root=tmp_path,
|
|
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
|
)
|
|
)
|
|
|
|
blocked = registry.execute("read_file", {"root": "runtime_data", "path": "settings.json"})
|
|
assert "DATA_READ_BLOCKED" in blocked
|
|
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": "settings.tmp"})
|
|
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": ".settings.json.tmp.123"})
|
|
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": ".env.local"})
|
|
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": "prod.env"})
|
|
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": "state/skills/weather/.grants.json.tmp.123"})
|
|
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": "state/skills/weather/review.json.lock"})
|
|
alias_result = registry.execute("read_file", {"root": "runtime_data", "path": "alias.txt"})
|
|
if (tmp_path / "alias.txt").exists():
|
|
assert "DATA_READ_BLOCKED" in alias_result
|
|
hardlink_result = registry.execute("read_file", {"root": "runtime_data", "path": "hardlink.txt"})
|
|
if (tmp_path / "hardlink.txt").exists():
|
|
assert "DATA_READ_BLOCKED" in hardlink_result
|
|
listing = registry.execute("list_files", {"root": "runtime_data", "path": "."})
|
|
assert "settings.json" not in listing
|
|
assert "settings.tmp" not in listing
|
|
assert ".settings.json.tmp.123" not in listing
|
|
assert ".env.local" not in listing
|
|
assert "prod.env" not in listing
|
|
assert "alias.txt" not in listing
|
|
assert "hardlink.txt" not in listing
|
|
assert "secret/control" in listing
|
|
skill_state_listing = registry.execute("list_files", {"root": "runtime_data", "path": "state/skills/weather"})
|
|
assert "grants.json" not in skill_state_listing
|
|
assert ".grants.json.tmp.123" not in skill_state_listing
|
|
assert "review.json.lock" not in skill_state_listing
|
|
assert "secret/control" in skill_state_listing
|
|
assert "DATA_LIST_BLOCKED" in registry.execute("list_files", {"root": "runtime_data", "path": "state/skills/weather/grants.json"})
|
|
assert "DATA_LIST_BLOCKED" in registry.execute("list_files", {"root": "runtime_data", "path": "state/skills/weather/.grants.json.tmp.123"})
|
|
readable = registry.execute("read_file", {"root": "runtime_data", "path": "logs/events.jsonl"})
|
|
assert "{}" in readable
|
|
|
|
|
|
def test_local_readonly_subagent_repo_read_denies_secret_files(tmp_path):
|
|
from ouroboros.contracts.task_constraint import TaskConstraint
|
|
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
|
|
|
repo = tmp_path / "repo"
|
|
data = tmp_path / "data"
|
|
(repo / ".git").mkdir(parents=True)
|
|
data.mkdir()
|
|
(repo / ".git" / "credentials").write_text("https://token@example.invalid\n", encoding="utf-8")
|
|
(repo / ".git" / "config").write_text("[credential]\n", encoding="utf-8")
|
|
(repo / ".env.local").write_text("TOKEN=secret\nLEAK_MARKER=env\n", encoding="utf-8")
|
|
(repo / "auth_token.json").write_text('{"token":"PROJECT_TOKEN_REPORT"}\n', encoding="utf-8")
|
|
(repo / "src").mkdir()
|
|
(repo / "src" / "public.py").write_text("print('ok')\n", encoding="utf-8")
|
|
(repo / "src" / "skill_token.py").write_text("TOKEN_NAME = 'safe source symbol'\n", encoding="utf-8")
|
|
try:
|
|
os.symlink(".git/credentials", repo / "alias.txt")
|
|
except (OSError, NotImplementedError):
|
|
pass
|
|
try:
|
|
os.link(repo / ".git" / "credentials", repo / "hardlink.txt")
|
|
except (OSError, NotImplementedError):
|
|
pass
|
|
|
|
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
|
registry.set_context(
|
|
ToolContext(
|
|
repo_dir=repo,
|
|
drive_root=data,
|
|
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
|
)
|
|
)
|
|
|
|
assert "REPO_READ_BLOCKED" in registry.execute("read_file", {"path": ".git/credentials"})
|
|
assert "READ_FILE_BLOCKED" in registry.execute("read_file", {"root": "system_repo", "path": ".git/credentials"})
|
|
assert "REPO_READ_BLOCKED" in registry.execute("read_file", {"path": ".git/config"})
|
|
assert "READ_FILE_BLOCKED" in registry.execute("read_file", {"root": "system_repo", "path": ".git/config"})
|
|
assert "REPO_READ_BLOCKED" in registry.execute("read_file", {"path": ".env.local"})
|
|
assert "PROJECT_TOKEN_REPORT" in registry.execute("read_file", {"path": "auth_token.json"})
|
|
alias_result = registry.execute("read_file", {"path": "alias.txt"})
|
|
if (repo / "alias.txt").exists():
|
|
assert "REPO_READ_BLOCKED" in alias_result
|
|
hardlink_result = registry.execute("read_file", {"path": "hardlink.txt"})
|
|
if (repo / "hardlink.txt").exists():
|
|
assert "REPO_READ_BLOCKED" in hardlink_result
|
|
listing = registry.execute("list_files", {"path": "."})
|
|
assert ".git/" not in listing
|
|
assert ".env.local" not in listing
|
|
assert "auth_token.json" in listing
|
|
assert "alias.txt" not in listing
|
|
assert "hardlink.txt" not in listing
|
|
assert "src/" in listing
|
|
assert "secret/control" in listing
|
|
system_listing = registry.execute("list_files", {"root": "system_repo", "path": "."})
|
|
assert ".git/" not in system_listing
|
|
assert "auth_token.json" in system_listing
|
|
assert "secret/control" in system_listing
|
|
assert "REPO_LIST_BLOCKED" in registry.execute("list_files", {"path": ".git"})
|
|
readable = registry.execute("read_file", {"path": "src/public.py"})
|
|
assert "print('ok')" in readable
|
|
source_with_token_name = registry.execute("read_file", {"path": "src/skill_token.py"})
|
|
assert "safe source symbol" in source_with_token_name
|
|
secret_search = registry.execute("search_code", {"query": "LEAK_MARKER"})
|
|
assert "No matches found" in secret_search
|
|
assert "auth_token.json:" not in secret_search
|
|
assert "auth_token.json:" in registry.execute("search_code", {"query": "PROJECT_TOKEN_REPORT", "path": "auth_token.json"})
|
|
public_search = registry.execute("search_code", {"query": "safe source symbol"})
|
|
assert "src/skill_token.py" in public_search
|
|
digest = registry.execute("query_code", {"op": "digest"})
|
|
# JSON contributes to inventory coverage but has no code-symbol digest row.
|
|
assert digest.startswith("Codebase Digest (3 files,")
|
|
assert ".env.local" not in digest
|
|
assert "src/skill_token.py" in digest
|
|
cached = list((data / "state" / "code_intel").glob("*/inventory.json"))
|
|
assert not cached
|
|
|
|
|
|
def test_local_readonly_subagent_task_drive_and_skill_payload_filters(tmp_path):
|
|
from ouroboros.contracts.task_constraint import TaskConstraint
|
|
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
|
|
|
repo = tmp_path / "repo"
|
|
data = tmp_path / "data"
|
|
repo.mkdir()
|
|
data.mkdir()
|
|
(data / "settings.json").write_text('{"OPENROUTER_API_KEY":"secret"}', encoding="utf-8")
|
|
(data / "skills" / "external" / "alpha").mkdir(parents=True)
|
|
(data / "skills" / "external" / "alpha" / "SKILL.md").write_text("hello", encoding="utf-8")
|
|
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
|
registry.set_context(
|
|
ToolContext(
|
|
repo_dir=repo,
|
|
drive_root=data,
|
|
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
|
)
|
|
)
|
|
|
|
from ouroboros.tool_access import resource_root_path
|
|
|
|
task_root = resource_root_path(registry._ctx, "task_drive")
|
|
task_root.mkdir(parents=True, exist_ok=True)
|
|
(task_root / "settings.json").write_text("ordinary task settings", encoding="utf-8")
|
|
(task_root / "auth_token.json").write_text("ordinary task output", encoding="utf-8")
|
|
assert "ordinary task settings" in registry.execute("read_file", {"root": "task_drive", "path": "settings.json"})
|
|
assert "ordinary task output" in registry.execute("read_file", {"root": "task_drive", "path": "auth_token.json"})
|
|
denied = registry.execute_result("read_file", {"root": "runtime_data", "path": "settings.json"})
|
|
assert denied.code == "DATA_BLOCKED"
|
|
assert denied.text == "⚠️ DATA_READ_BLOCKED: this subagent cannot read secret or owner-control data files."
|
|
traversal = registry.execute(
|
|
"read_file",
|
|
{"root": "skill_payload", "bucket": "external", "skill_name": "../../settings.json", "path": "."},
|
|
)
|
|
assert "TOOL_ACCESS_BLOCKED" in traversal or "READ_FILE_ERROR" in traversal or "TOOL_ARG_ERROR" in traversal
|
|
skill_payload_read = registry.execute(
|
|
"read_file",
|
|
{"root": "skill_payload", "bucket": "external", "skill_name": "alpha", "path": "SKILL.md"},
|
|
)
|
|
# v6.70.0 (owner-approved): read-only scouts may READ skill payloads — a scout
|
|
# sent to review a skill used to be structurally blind to it. Mutation stays
|
|
# blocked (pinned in test_owner_facing_honesty.py).
|
|
assert "TOOL_ACCESS_BLOCKED" not in skill_payload_read
|
|
assert "hello" in skill_payload_read
|