mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
116 lines
3.6 KiB
Python
116 lines
3.6 KiB
Python
"""/usage/sync contains transcript_path under the Claude projects dir.
|
|||
|
|
|
||
|
|
The endpoint is an unauthenticated container-internal HTTP surface; a forged
|
||
|
|
``transcript_path`` could ``stat`` / read arbitrary files. The guard requires
|
||
|
|
a ``.jsonl`` suffix and that the resolved path stays under the transcript
|
||
|
|
root (``ROBOCO_TRANSCRIPT_DIR``, default ``~/.claude/projects``). Existing
|
||
|
|
behavior — a missing-but-contained transcript returns 200 with zeroed totals
|
||
|
|
— is preserved.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from typing import TYPE_CHECKING
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import roboco.agent_sdk.server as srv
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from collections.abc import Iterator
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
_BAD = 400
|
||
|
|
_OK = 200
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def _reset_state() -> Iterator[None]:
|
||
|
|
srv._state.reset()
|
||
|
|
yield
|
||
|
|
srv._state.reset()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def _transcript_base(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
# Contain transcripts under tmp_path so the guard's base is the test dir.
|
||
|
|
monkeypatch.setenv("ROBOCO_TRANSCRIPT_DIR", str(tmp_path))
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def client() -> TestClient:
|
||
|
|
return TestClient(srv.app)
|
||
|
|
|
||
|
|
|
||
|
|
_IN_TOKENS = 10
|
||
|
|
_OUT_TOKENS = 2
|
||
|
|
|
||
|
|
|
||
|
|
def _assistant_line() -> str:
|
||
|
|
return json.dumps(
|
||
|
|
{
|
||
|
|
"type": "assistant",
|
||
|
|
"message": {
|
||
|
|
"role": "assistant",
|
||
|
|
"usage": {
|
||
|
|
"input_tokens": _IN_TOKENS,
|
||
|
|
"output_tokens": _OUT_TOKENS,
|
||
|
|
"cache_read_input_tokens": 0,
|
||
|
|
"cache_creation_input_tokens": 0,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_non_jsonl_path(client: TestClient) -> None:
|
||
|
|
"""``/etc/passwd`` has no .jsonl suffix → 400, never stat'd."""
|
||
|
|
resp = client.post("/usage/sync", json={"transcript_path": "/etc/passwd"})
|
||
|
|
assert resp.status_code == _BAD
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_absolute_path_outside_base(
|
||
|
|
client: TestClient, tmp_path_factory: pytest.TempPathFactory
|
||
|
|
) -> None:
|
||
|
|
outside = tmp_path_factory.mktemp("outside") / "leak.jsonl"
|
||
|
|
outside.write_text("{}", encoding="utf-8")
|
||
|
|
resp = client.post("/usage/sync", json={"transcript_path": str(outside)})
|
||
|
|
assert resp.status_code == _BAD
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_traversal_escape(client: TestClient, tmp_path: Path) -> None:
|
||
|
|
"""A .jsonl path that resolves outside the base → 400."""
|
||
|
|
escape = tmp_path / ".." / "secret.jsonl"
|
||
|
|
resp = client.post("/usage/sync", json={"transcript_path": str(escape)})
|
||
|
|
assert resp.status_code == _BAD
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_empty_path(client: TestClient) -> None:
|
||
|
|
resp = client.post("/usage/sync", json={"transcript_path": ""})
|
||
|
|
assert resp.status_code == _BAD
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_nul_in_path(client: TestClient) -> None:
|
||
|
|
resp = client.post("/usage/sync", json={"transcript_path": "be\x00evil.jsonl"})
|
||
|
|
assert resp.status_code == _BAD
|
||
|
|
|
||
|
|
|
||
|
|
def test_accepts_transcript_under_base(client: TestClient, tmp_path: Path) -> None:
|
||
|
|
transcript = tmp_path / "session.jsonl"
|
||
|
|
transcript.write_text(_assistant_line() + "\n", encoding="utf-8")
|
||
|
|
resp = client.post("/usage/sync", json={"transcript_path": str(transcript)})
|
||
|
|
assert resp.status_code == _OK
|
||
|
|
assert resp.json()["tokens_input"] == _IN_TOKENS
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_transcript_under_base_returns_200(
|
||
|
|
client: TestClient, tmp_path: Path
|
||
|
|
) -> None:
|
||
|
|
"""A contained-but-not-yet-written transcript still returns 200 (graceful)."""
|
||
|
|
resp = client.post(
|
||
|
|
"/usage/sync", json={"transcript_path": str(tmp_path / "nope.jsonl")}
|
||
|
|
)
|
||
|
|
assert resp.status_code == _OK
|
||
|
|
assert resp.json()["tokens_input"] == 0
|