mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 20:02:20 +02:00
fix(harness): ship coherent vendor harness cluster
Rebuild PR C to ship the vendor/photocraft harness as a coherent unit (vendor implementation + vendor tests) instead of a per-file ahead/behind mix that produced broken imports and 6+ test failures. - Flip 36 harness impl/route/test files back to vendor (the per-file line-set policy misclassified user-rewritten files as 'behind'). - Flip core/models.py + src/tool_approval_scopes.py to vendor: vendor's harness (authority.py, turn_contract.py, clean_agent_preview.py) depends on vendor's older approval-scope API; upstream added CHAT_SESSION_APPROVAL_SIGNATURE_FIELD that vendor's core/models.py lacks. - Add src/shutdown.py (new vendor infra) required by routes/chat_routes.py. - Keep src/agent_tools/__init__.py at upstream (editor_tools registration lives in PR B). Upstream-only security divergence preserved in C_upstream_delta.patch and flagged for integration-time reconciliation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
364229f2dc
commit
9bdea753ae
@@ -1873,8 +1873,7 @@ def test_tui_local_skill_request_reconciles_normal_tool_policy(monkeypatch):
|
||||
},
|
||||
)))
|
||||
|
||||
# Caller hard denials are absolute and cannot be re-enabled by later TUI turn contracts.
|
||||
assert calls == []
|
||||
assert calls == ["manage_skills"]
|
||||
assert not any(e.get("type") == "loop_breaker_triggered" for e in events)
|
||||
|
||||
|
||||
|
||||
@@ -49,9 +49,8 @@ def test_scheduler_fallbacks_and_research_headers_are_owner_scoped():
|
||||
assert "owner=task.owner or None" in src
|
||||
assert "headers_from_resolver = False" in src
|
||||
assert "headers_from_resolver = True" in src
|
||||
assert "resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None)" in src
|
||||
assert "resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)" in src
|
||||
assert src.count("resolve_endpoint_runtime(ep, owner=task.owner or None)") == 2
|
||||
assert "from src.auth_helpers import owner_filter" in src
|
||||
assert "owner_filter(ep_q, ModelEndpoint, task.owner or None)" in src
|
||||
|
||||
|
||||
def test_research_routes_fallbacks_are_owner_scoped():
|
||||
|
||||
@@ -49,6 +49,9 @@ async def _create_with_reminder(reminder, owner):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reminder,expected", [
|
||||
(0, 0), # At-event reminder must not compare equal to False.
|
||||
(0.0, 0),
|
||||
("0", 0),
|
||||
("5 mins", 5),
|
||||
("10 mins", 10),
|
||||
("2 hrs", 120),
|
||||
@@ -91,6 +94,15 @@ async def test_no_reminder_when_offset_absent():
|
||||
assert "reminder set" not in res.get("response", ""), res
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reminder", [False, "off", "false", None])
|
||||
async def test_disabled_reminder_is_distinct_from_zero(reminder):
|
||||
owner = "tester-" + uuid.uuid4().hex[:6]
|
||||
res = await _create_with_reminder(reminder, owner)
|
||||
assert res.get("exit_code") == 0, res
|
||||
assert not res.get("has_reminder"), res
|
||||
assert res.get("reminder_note_id") is None, res
|
||||
|
||||
|
||||
async def test_update_event_can_add_reminder_after_creation():
|
||||
owner = "tester-" + uuid.uuid4().hex[:6]
|
||||
from src.tool_implementations import do_manage_calendar
|
||||
@@ -123,37 +135,3 @@ async def test_update_event_can_add_reminder_after_creation():
|
||||
assert note is not None, "update_event should create the reminder note"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def test_reminder_and_duration_parsers_stay_linear_on_digit_and_space_floods():
|
||||
"""CodeQL py/polynomial-redos: `(\\d+)\\s*unit` rescanned a digit run from
|
||||
every offset and `alarm\\s*:?\\s*\\d+` split one whitespace run two ways.
|
||||
Tool arguments come from model output, so keep them O(n)."""
|
||||
import time
|
||||
from src.tool_implementations import do_manage_calendar
|
||||
|
||||
owner = "tester-" + uuid.uuid4().hex[:6]
|
||||
started = time.perf_counter()
|
||||
res = await do_manage_calendar(json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Flood",
|
||||
"dtstart": "2030-04-01T10:00:00",
|
||||
"reminder_minutes": "0" * 40_000 + "x",
|
||||
"duration": "0" * 40_000 + "x",
|
||||
}), owner=owner)
|
||||
assert time.perf_counter() - started < 2.0
|
||||
assert res.get("exit_code") == 0, res
|
||||
assert "reminder set" not in res.get("response", ""), res
|
||||
|
||||
# A reminder is set, so the "is the description only a reminder?" check runs.
|
||||
started = time.perf_counter()
|
||||
res = await do_manage_calendar(json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Flood 2",
|
||||
"dtstart": "2030-04-02T10:00:00",
|
||||
"description": "alarm" + "\t" * 40_000,
|
||||
"reminder_minutes": 5,
|
||||
}), owner=owner)
|
||||
assert time.perf_counter() - started < 2.0
|
||||
assert res.get("exit_code") == 0, res
|
||||
assert "reminder 5 min before" in res.get("response", ""), res
|
||||
|
||||
@@ -624,8 +624,7 @@ def test_bridge_post_targets_endpoint_paths():
|
||||
posted = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
posted["timeout"] = timeout
|
||||
|
||||
async def __aenter__(self):
|
||||
@@ -696,8 +695,8 @@ def test_bridge_post_treats_empty_or_error_payload_as_failure():
|
||||
payloads = iter(({}, {"error": "token rejected"}))
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -735,8 +734,8 @@ def test_bridge_post_http_or_payload_error_overrides_false_success_code():
|
||||
))
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -770,8 +769,8 @@ def test_bridge_post_rejects_malformed_exit_code():
|
||||
return {"output": "looks successful", "exit_code": "zero"}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -805,8 +804,8 @@ def test_bridge_post_reports_empty_invalid_json_response():
|
||||
raise ValueError("not JSON")
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -834,8 +833,8 @@ def test_bridge_post_reports_empty_invalid_json_response():
|
||||
def test_bridge_post_cancellation_schedules_host_operation_cancel():
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
@@ -61,20 +61,11 @@ def test_grep_ignore_case(repo):
|
||||
assert "b.txt:2:" in r["output"]
|
||||
|
||||
|
||||
def test_grep_glob_filter(repo, monkeypatch):
|
||||
def test_grep_glob_filter(repo):
|
||||
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "a.py" in r["output"]
|
||||
assert "b.txt" not in r["output"]
|
||||
|
||||
# Positive control: verify that fallback path honors the exact same glob contract
|
||||
real_which = shutil.which
|
||||
monkeypatch.setattr(shutil, "which", lambda name, *a, **kw: None if name == "rg" else real_which(name, *a, **kw))
|
||||
r_fallback = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
|
||||
assert r_fallback["exit_code"] == 0
|
||||
assert "a.py" in r_fallback["output"]
|
||||
assert "b.txt" not in r_fallback["output"]
|
||||
|
||||
|
||||
def test_grep_no_match(repo):
|
||||
r = _run("grep", f'{{"pattern": "zzzznotfound", "path": "{repo}"}}')
|
||||
@@ -103,17 +94,6 @@ def test_grep_python_fallback_when_no_rg(repo, monkeypatch):
|
||||
assert ".git/config" not in r["output"]
|
||||
|
||||
|
||||
def test_grep_python_fallback_uses_relative_glob_paths(repo, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
r = _run(
|
||||
"grep",
|
||||
f'{{"pattern": "needle|python", "glob": "**/*.py", "path": "{repo}"}}',
|
||||
)
|
||||
assert r["exit_code"] == 0
|
||||
assert "a.py" in r["output"]
|
||||
assert "sub/deep/c.py" in r["output"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rg") is None, reason="targets the ripgrep fast-path")
|
||||
def test_grep_skips_case_variant_sensitive_files_rg(repo):
|
||||
"""The rg fast-path must exclude deny-listed key files case-insensitively.
|
||||
|
||||
@@ -179,16 +179,8 @@ def test_cancelled_write_unsettles_an_earlier_success(run, ws, monkeypatch):
|
||||
|
||||
def test_mid_write_failure_unsettles_but_refusal_preserves(run, ws, monkeypatch):
|
||||
run("write_file", {"path": "a.txt", "content": "hello\n"})
|
||||
# Explicit empty JSON clear/write is authoritative and succeeds.
|
||||
run("write_file", {"path": "a.txt", "content": ""})
|
||||
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.REPORTED_SUCCESS
|
||||
|
||||
# Re-seed the artifact content
|
||||
run("write_file", {"path": "a.txt", "content": "hello\n"})
|
||||
assert ledger(run.journal, ws).evaluate().can_complete
|
||||
|
||||
# A deterministic refusal before the mutation stage keeps the artifact.
|
||||
run("write_file", "a.txt\n")
|
||||
run("write_file", {"path": "a.txt", "content": ""})
|
||||
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.FAILED
|
||||
assert ledger(run.journal, ws).evaluate().can_complete
|
||||
|
||||
|
||||
@@ -167,56 +167,10 @@ def test_renders_one_table_row_per_variable(generator, fake_tree, monkeypatch):
|
||||
page = generator.render(found, generator.naive_line_scan(fake_tree))
|
||||
|
||||
assert page.startswith("---\nlayout: default\n---\n")
|
||||
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py` | A synthetic limit. |" in page
|
||||
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py:16` | A synthetic limit. |" in page
|
||||
assert "### Search" in page
|
||||
|
||||
|
||||
def _render_one(generator, root, name, monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
generator.VARIABLE_NOTES, name, ("Search", generator.USER, "A synthetic flag.")
|
||||
)
|
||||
return generator.render(
|
||||
{name: generator.collect(root)[name]}, generator.naive_line_scan(root)
|
||||
)
|
||||
|
||||
|
||||
def test_page_does_not_change_when_an_unrelated_edit_shifts_lines(
|
||||
generator, fake_tree, monkeypatch
|
||||
):
|
||||
"""An edit that moves a read down a file must not make the page stale.
|
||||
|
||||
The page used to cite `path:line`, so any PR adding a line above a read had
|
||||
to regenerate it and then conflicted with every other PR that had.
|
||||
"""
|
||||
before = _render_one(generator, fake_tree, "ODYSSEUS_VIA_HELPER", monkeypatch)
|
||||
source = fake_tree / "src" / "indirect.py"
|
||||
source.write_text("import sys\n\n\n" + source.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
after = _render_one(generator, fake_tree, "ODYSSEUS_VIA_HELPER", monkeypatch)
|
||||
|
||||
assert generator.collect(fake_tree)["ODYSSEUS_VIA_HELPER"].primary.lineno == 19
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_extra_reads_are_counted_per_file(generator, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(generator, "SOURCE_ROOTS", ("app.py", "src"))
|
||||
_write(tmp_path, "app.py", """
|
||||
import os
|
||||
|
||||
FIRST = os.environ.get("ODYSSEUS_READ_OFTEN", "1")
|
||||
SECOND = os.environ.get("ODYSSEUS_READ_OFTEN", "1")
|
||||
""")
|
||||
_write(tmp_path, "src/elsewhere.py", """
|
||||
import os
|
||||
|
||||
AGAIN = os.getenv("ODYSSEUS_READ_OFTEN")
|
||||
""")
|
||||
|
||||
page = _render_one(generator, tmp_path, "ODYSSEUS_READ_OFTEN", monkeypatch)
|
||||
|
||||
assert "| `app.py` (+1 more) |" in page
|
||||
|
||||
|
||||
def test_every_variable_read_in_the_repository_is_documented(built):
|
||||
_, variables, problems = built
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import logging
|
||||
import src.tool_execution as tool_execution
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
@@ -17,60 +15,6 @@ from src.tool_execution import (
|
||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", ['{"path":"/workspace/x"}', '{"path":"/workspace/x","content":null}',
|
||||
'/workspace/x', '/workspace/x\n', '/workspace/x\n \t',
|
||||
json.dumps({'path': '/workspace/x.py', 'content': '```python\n\n```'})])
|
||||
def test_bridge_refuses_missing_or_ambiguous_empty_content(monkeypatch, tmp_path, content):
|
||||
async def forbidden(*args, **kwargs):
|
||||
raise AssertionError("ambiguous write reached remote producer")
|
||||
monkeypatch.setattr(tool_execution, "_client_bridge", lambda context: {"url": "http://bridge", "token": "x"})
|
||||
monkeypatch.setattr(tool_execution, "_bridge_post", forbidden)
|
||||
async def invoke():
|
||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
||||
try:
|
||||
return await tool_execution._route_tool_via_bridge("write_file", content, "s", {})
|
||||
finally:
|
||||
tool_execution._active_workspace.reset(token)
|
||||
_, result = asyncio.run(invoke())
|
||||
assert result["exit_code"] == 1
|
||||
assert "content" in result["error"] or "JSON" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["", " \t", "normal source"])
|
||||
def test_bridge_honors_explicit_json_string_content(monkeypatch, tmp_path, body):
|
||||
import base64
|
||||
seen = []
|
||||
async def post(bridge, endpoint, payload, **kwargs):
|
||||
seen.append((endpoint, base64.b64decode(payload["content_b64"])))
|
||||
return {"output": "written", "exit_code": 0}
|
||||
monkeypatch.setattr(tool_execution, "_client_bridge", lambda context: {"url": "http://bridge", "token": "x"})
|
||||
monkeypatch.setattr(tool_execution, "_bridge_post", post)
|
||||
async def invoke():
|
||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
||||
try:
|
||||
return await tool_execution._route_tool_via_bridge("write_file", json.dumps({"path": "/workspace/x", "content": body}), "s", {})
|
||||
finally:
|
||||
tool_execution._active_workspace.reset(token)
|
||||
_, result = asyncio.run(invoke())
|
||||
assert result["exit_code"] == 0
|
||||
assert seen == [("/write", body.encode())]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", ['/workspace/x\n', '{"path":"/workspace/x"}', '/workspace/x\n \t'])
|
||||
def test_scoped_bridge_cannot_blindly_write_implicit_empty(content):
|
||||
async def forbidden(*args):
|
||||
raise AssertionError("scoped bridge received an ambiguous destructive write")
|
||||
bridge = AgentExecutionBridge(forbidden, frozenset({'write_file'}), 'empty-intent-test')
|
||||
async def invoke():
|
||||
with bind_execution_bridge(bridge):
|
||||
block = Block(content)
|
||||
block.tool_type = 'write_file'
|
||||
return await execute_tool_block(block, security_context=NO_TOOL_SECURITY_CONTEXT)
|
||||
_, result = asyncio.run(invoke())
|
||||
assert result['exit_code'] == 1
|
||||
assert 'content' in result['error']
|
||||
|
||||
|
||||
class Block:
|
||||
tool_type = "host_shell"
|
||||
|
||||
@@ -215,7 +159,7 @@ def test_scoped_execution_bridge_failure_logs_compact_warning(caplog) -> None:
|
||||
assert "Scoped execution bridge test-environment failed for tool=host_shell" in caplog.text
|
||||
|
||||
|
||||
def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch, tmp_path) -> None:
|
||||
def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch) -> None:
|
||||
"""The bridge path must preserve rendered media just like local writes."""
|
||||
called = False
|
||||
|
||||
@@ -228,17 +172,12 @@ def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch, tmp
|
||||
monkeypatch.setattr(tool_execution, "_bridge_post", fake_post)
|
||||
|
||||
async def invoke():
|
||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
||||
try:
|
||||
return await tool_execution._route_tool_via_bridge(
|
||||
"write_file",
|
||||
'{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}',
|
||||
"run-1",
|
||||
{"surface": "odysseus-tui"},
|
||||
)
|
||||
|
||||
finally:
|
||||
tool_execution._active_workspace.reset(token)
|
||||
return await tool_execution._route_tool_via_bridge(
|
||||
"write_file",
|
||||
'{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}',
|
||||
"run-1",
|
||||
{"surface": "odysseus-tui"},
|
||||
)
|
||||
|
||||
description, result = asyncio.run(invoke())
|
||||
|
||||
|
||||
@@ -984,7 +984,7 @@ def test_attachment_extract_dir_stays_contained(folder, uid):
|
||||
"""User-controlled folder/uid must never escape ATTACHMENTS_DIR — pins the
|
||||
fix for the attachment-extraction path traversal."""
|
||||
aed, base = _import_attachment_extract_dir()
|
||||
target = aed(folder, uid, owner="../../owner", account_id="../acct")
|
||||
target = aed(folder, uid)
|
||||
base_r = base.resolve()
|
||||
assert target == base_r or base_r in target.parents
|
||||
# exactly one extra path segment, and no `..` component survived
|
||||
@@ -992,11 +992,9 @@ def test_attachment_extract_dir_stays_contained(folder, uid):
|
||||
assert ".." not in rel.parts
|
||||
|
||||
|
||||
def test_attachment_extract_dir_is_stable_single_segment_per_scope():
|
||||
def test_attachment_extract_dir_normal_inputs_unchanged():
|
||||
aed, base = _import_attachment_extract_dir()
|
||||
target = aed("INBOX", "123", owner="alice", account_id="acct-1")
|
||||
assert target == aed("INBOX", "123", owner="alice", account_id="acct-1")
|
||||
assert target.parent == base.resolve()
|
||||
assert aed("INBOX", "123") == base.resolve() / "INBOX_123"
|
||||
|
||||
|
||||
def test_diagnostics_routes_are_admin_gated():
|
||||
|
||||
@@ -6,21 +6,13 @@ from fastapi import HTTPException
|
||||
|
||||
# Import the route helper during collection so sibling session tests that use
|
||||
# partial import stubs do not become the first loader of core.session_manager.
|
||||
from routes.session_routes import (
|
||||
_reject_delegated_session_options,
|
||||
_reject_raw_endpoint_url_for_non_admin,
|
||||
)
|
||||
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||
|
||||
|
||||
def _request(user, *, admin=False, api_token=False, scopes=None):
|
||||
def _request(user, *, admin=False):
|
||||
auth_manager = SimpleNamespace(is_admin=lambda username: bool(admin))
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
current_user="api" if api_token else user,
|
||||
api_token=api_token,
|
||||
api_token_owner=user if api_token else None,
|
||||
api_token_scopes=scopes or [],
|
||||
),
|
||||
state=SimpleNamespace(current_user=user),
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
|
||||
)
|
||||
|
||||
@@ -52,47 +44,6 @@ def test_admin_and_registered_endpoint_can_use_endpoint_url():
|
||||
)
|
||||
|
||||
|
||||
def test_bearer_token_does_not_inherit_owner_admin_raw_endpoint_authority():
|
||||
request = _request("admin", admin=True, api_token=True, scopes=["chat"])
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_reject_raw_endpoint_url_for_non_admin(
|
||||
request,
|
||||
"admin",
|
||||
"",
|
||||
"http://127.0.0.1:8000/v1/chat/completions",
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_chat_scoped_bearer_can_still_choose_an_owner_registered_endpoint():
|
||||
_reject_raw_endpoint_url_for_non_admin(
|
||||
_request("admin", admin=True, api_token=True, scopes=["chat"]),
|
||||
"admin",
|
||||
"owner-endpoint-id",
|
||||
"http://127.0.0.1:8000/v1/chat/completions",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("skip_validation", "api_key"),
|
||||
[(True, ""), (False, "caller-secret")],
|
||||
)
|
||||
def test_bearer_token_cannot_use_interactive_session_options(
|
||||
skip_validation,
|
||||
api_key,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_reject_delegated_session_options(
|
||||
_request("admin", admin=True, api_token=True, scopes=["chat"]),
|
||||
skip_validation=skip_validation,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_chat_endpoint_recovery_paths_are_owner_scoped():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
chat_routes = (root / "routes" / "chat_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
@@ -169,14 +169,12 @@ def test_blocks_netrc():
|
||||
_resolve_tool_path("~/.netrc")
|
||||
|
||||
|
||||
def test_allows_agent_workspace(tmp_path):
|
||||
"""Paths under the agent's workspace in project data/ must resolve
|
||||
cleanly. The rest of data/ is application state and is rejected;
|
||||
tests/test_agent_state_dir_confinement.py covers that side."""
|
||||
def test_allows_project_data(tmp_path):
|
||||
"""Paths under project data/ must resolve cleanly."""
|
||||
from src.tool_execution import _resolve_tool_path
|
||||
from src.constants import AGENT_WORKSPACE_DIR
|
||||
target = os.path.join(AGENT_WORKSPACE_DIR, "test-confinement-ok.txt")
|
||||
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
|
||||
from src.constants import DATA_DIR
|
||||
target = os.path.join(DATA_DIR, "test-confinement-ok.txt")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(target, "w") as f:
|
||||
f.write("ok")
|
||||
try:
|
||||
@@ -315,8 +313,8 @@ async def test_write_file_dispatch_rejects_empty_directory_like_workspace_path(m
|
||||
workspace=str(tmp_path),
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
)
|
||||
assert "BLOCKED" in desc
|
||||
assert "content required; missing content section" in (
|
||||
assert desc == "write_file: /workspace/papers"
|
||||
assert "content required; refusing to create an empty file" in (
|
||||
result.get("error") or ""
|
||||
)
|
||||
assert result.get("exit_code") == 1
|
||||
|
||||
+11
-35
@@ -3,8 +3,6 @@
|
||||
A stub resolver is injected so the tests never touch real DNS.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
|
||||
@@ -66,39 +64,6 @@ def test_strict_mode_blocks_private_and_loopback():
|
||||
assert ok is False and "private" in reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("block_private", [False, True])
|
||||
@pytest.mark.parametrize("host,ips", [
|
||||
("127.0.0.1", ["127.0.0.1"]),
|
||||
("[::1]", ["::1"]),
|
||||
("[::ffff:127.0.0.1]", ["::ffff:127.0.0.1"]),
|
||||
("localhost", ["127.0.0.1", "::1"]),
|
||||
("localhost", ["::1", "127.0.0.1"]),
|
||||
])
|
||||
def test_loopback_urls_follow_private_policy(host, ips, block_private):
|
||||
ok, reason = check_outbound_url(
|
||||
f"http://{host}:8080", block_private=block_private,
|
||||
resolver=_resolver({host.strip("[]"): ips}),
|
||||
)
|
||||
assert ok is (not block_private), reason
|
||||
if block_private:
|
||||
assert "loopback address blocked" in reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("block_private", [False, True])
|
||||
@pytest.mark.parametrize("ip", [
|
||||
"169.254.169.254", "fe80::1", "0.0.0.0", "::", "224.0.0.1", "ff02::1",
|
||||
"240.0.0.1", "::2", "100::1", "::ffff:169.254.169.254",
|
||||
"::ffff:0.0.0.0", "::ffff:224.0.0.1", "::ffff:240.0.0.1",
|
||||
])
|
||||
def test_special_ranges_stay_blocked_alongside_loopback(ip, block_private):
|
||||
ok, reason = check_outbound_url(
|
||||
"http://localhost:8080", block_private=block_private,
|
||||
resolver=_resolver({"localhost": [ip, "127.0.0.1", "::1"]}),
|
||||
)
|
||||
assert ok is False
|
||||
assert "link-local" in reason or "disallowed address" in reason
|
||||
|
||||
|
||||
def test_strict_mode_blocks_cgnat_shared_space():
|
||||
# RFC 6598 shared/CGNAT space (100.64.0.0/10) is not globally routable.
|
||||
# A public redirect into it must be rejected under full SSRF lockdown,
|
||||
@@ -282,3 +247,14 @@ def test_ordinary_ipv6_behaviour_is_unchanged():
|
||||
LL6 = _resolver({"ll.example": ["fe80::1"]})
|
||||
ok, reason = check_outbound_url("http://ll.example/", resolver=LL6)
|
||||
assert ok is False and "link-local" in reason
|
||||
|
||||
# Pre-existing behaviour, unchanged here: CPython reports ::1 as
|
||||
# is_reserved, so IPv6 loopback is rejected in both modes (unlike 127.0.0.1,
|
||||
# which the local-first default allows).
|
||||
LOOP6 = _resolver({"loop6.example": ["::1"]})
|
||||
for strict in (False, True):
|
||||
ok, reason = check_outbound_url(
|
||||
"http://loop6.example/", block_private=strict, resolver=LOOP6
|
||||
)
|
||||
assert ok is False, strict
|
||||
assert "NAT64" not in reason
|
||||
|
||||
Reference in New Issue
Block a user