ltcai 0.1.23 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -9
- package/docs/CHANGELOG.md +25 -0
- package/package.json +1 -2
- package/server.py +17 -4
- package/tests/__init__.py +0 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/test_api.py +0 -94
- package/tests/unit/__init__.py +0 -0
- package/tests/unit/test_security.py +0 -193
- package/tests/unit/test_setup_wizard.py +0 -35
- package/tests/unit/test_tools.py +0 -320
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img src="docs/images/logo.svg" alt="Lattice AI" width="280"/>
|
|
2
|
+
<img src="https://raw.githubusercontent.com/TaeSooPark-PTS/LatticeAI/main/docs/images/logo.svg" alt="Lattice AI" width="280"/>
|
|
3
3
|
<br/>
|
|
4
4
|
<strong>Your personal AI workspace server — local & cloud, one stack.</strong>
|
|
5
5
|
<br/><br/>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://pypi.org/project/ltcai/)
|
|
8
8
|
[](https://pypi.org/project/ltcai/)
|
|
9
9
|
[](https://www.npmjs.com/package/ltcai)
|
|
10
|
-
[](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai)
|
|
11
11
|
[](https://open-vsx.org/extension/parktaesoo/ltcai)
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
[](https://www.python.org/)
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
|
|
33
33
|
<table>
|
|
34
34
|
<tr>
|
|
35
|
-
<td width="33%"><b>Chat UI</b><br/><img src="docs/images/screenshot-chat.png" alt="Lattice AI Chat" width="100%"/></td>
|
|
36
|
-
<td width="33%"><b>Admin Dashboard</b><br/><img src="docs/images/screenshot-admin.png" alt="Admin Dashboard" width="100%"/></td>
|
|
37
|
-
<td width="33%"><b>Data Graph (Graph RAG)</b><br/><img src="docs/images/screenshot-graph.png" alt="Knowledge Graph" width="100%"/></td>
|
|
35
|
+
<td width="33%"><b>Chat UI</b><br/><img src="https://raw.githubusercontent.com/TaeSooPark-PTS/LatticeAI/main/docs/images/screenshot-chat.png" alt="Lattice AI Chat" width="100%"/></td>
|
|
36
|
+
<td width="33%"><b>Admin Dashboard</b><br/><img src="https://raw.githubusercontent.com/TaeSooPark-PTS/LatticeAI/main/docs/images/screenshot-admin.png" alt="Admin Dashboard" width="100%"/></td>
|
|
37
|
+
<td width="33%"><b>Data Graph (Graph RAG)</b><br/><img src="https://raw.githubusercontent.com/TaeSooPark-PTS/LatticeAI/main/docs/images/screenshot-graph.png" alt="Knowledge Graph" width="100%"/></td>
|
|
38
38
|
</tr>
|
|
39
39
|
</table>
|
|
40
40
|
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
|
|
43
43
|
## ⚡ Quick Start (30 seconds)
|
|
44
44
|
|
|
45
|
+
**Python / PyPI**
|
|
46
|
+
|
|
45
47
|
```bash
|
|
46
48
|
# Install (cloud models)
|
|
47
49
|
pip install ltcai
|
|
@@ -49,21 +51,43 @@ pip install ltcai
|
|
|
49
51
|
# Install (+ Apple Silicon local models)
|
|
50
52
|
pip install "ltcai[local]"
|
|
51
53
|
|
|
54
|
+
# Verify environment
|
|
55
|
+
LTCAI doctor
|
|
56
|
+
|
|
52
57
|
# Start server
|
|
53
58
|
LTCAI
|
|
54
59
|
# → http://localhost:4825
|
|
60
|
+
```
|
|
55
61
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
**Node / npm**
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install -g ltcai
|
|
66
|
+
LTCAI doctor
|
|
67
|
+
LTCAI
|
|
59
68
|
```
|
|
60
69
|
|
|
70
|
+
**VS Code / Cursor**
|
|
71
|
+
|
|
72
|
+
1. Install **Lattice AI** from [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) or [Open VSX](https://open-vsx.org/extension/parktaesoo/ltcai)
|
|
73
|
+
2. Start the local server with `LTCAI`
|
|
74
|
+
3. Run `Lattice AI: Open Chat` (`Cmd+Shift+A`) in your editor
|
|
75
|
+
|
|
61
76
|
**First run:** open `http://localhost:4825` → sign up → first account auto-becomes admin → pick a model → start chatting.
|
|
62
77
|
|
|
78
|
+
**Public HTTPS tunnel (Cloudflare, no account needed):**
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
LTCAI --tunnel
|
|
82
|
+
# → https://xxxx.trycloudflare.com
|
|
83
|
+
```
|
|
84
|
+
|
|
63
85
|
---
|
|
64
86
|
|
|
65
87
|
## 🆚 Why Lattice AI?
|
|
66
88
|
|
|
89
|
+
Comparison is based on public product behavior as of 2026-05.
|
|
90
|
+
|
|
67
91
|
| | Lattice AI | Open WebUI | Continue.dev | GitHub Copilot |
|
|
68
92
|
|---|:---:|:---:|:---:|:---:|
|
|
69
93
|
| Local model (offline, Apple Silicon) | ✅ | ✅ | ✅ | ❌ |
|
|
@@ -270,7 +294,7 @@ Or: `./start_ai.sh` (auto-restart + caffeinate)
|
|
|
270
294
|
| VS Code Marketplace | [marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) |
|
|
271
295
|
| Open VSX | [open-vsx.org](https://open-vsx.org/extension/parktaesoo/ltcai) |
|
|
272
296
|
|
|
273
|
-
Current version: **0.1.
|
|
297
|
+
Current version: **0.1.24** — [Changelog](docs/CHANGELOG.md)
|
|
274
298
|
|
|
275
299
|
---
|
|
276
300
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.24] - 2026-05-24
|
|
4
|
+
|
|
5
|
+
### 안정화 및 UX 개선
|
|
6
|
+
|
|
7
|
+
- **로컬 파일 인증 강화** — `/local/list` · `/local/read` · `/local/write` · `/local/serve`에서 로그인 세션 필수화 (`_require_local_user` 헬퍼 도입)
|
|
8
|
+
- **`GET /local/list` 라우트 추가** — smoke-test 및 브라우저 직접 호출 호환
|
|
9
|
+
- **VS Code 배지 수정** — shields.io `visual-studio-marketplace` 폐기 → `vsmarketplacebadges.dev`로 전환
|
|
10
|
+
- **README 이미지 URL 안정화** — 로고·스크린샷을 `raw.githubusercontent.com` 절대 URL로 전환해 PyPI / npm / Marketplace 페이지에서도 표시
|
|
11
|
+
- **Quick Start 분리** — PyPI / npm / VS Code 사용자의 첫 설치 경로를 각각 명확히 안내
|
|
12
|
+
- **GitHub Actions Node 24** — CI 런타임을 Node 24로 업그레이드
|
|
13
|
+
|
|
14
|
+
### Release
|
|
15
|
+
- 배포 버전을 `0.1.24`로 상향
|
|
16
|
+
- 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
|
|
17
|
+
|
|
3
18
|
## [0.1.23] - 2026-05-24
|
|
4
19
|
|
|
5
20
|
### Discord 권한 알림 시스템
|
|
@@ -19,12 +34,22 @@
|
|
|
19
34
|
- **영어 README** 전면 재작성 — 한국어는 접을 수 있는 `<details>` 섹션으로 이동
|
|
20
35
|
- **SVG 로고** 추가 (`docs/images/logo.svg`)
|
|
21
36
|
- **경쟁 제품 비교표** — Lattice AI vs Open WebUI · Continue.dev · GitHub Copilot
|
|
37
|
+
- **Quick Start 분리** — PyPI / npm / VS Code 사용자의 첫 설치 경로를 각각 명확히 안내
|
|
38
|
+
- **비교표 기준 명시** — 공개 제품 동작 기준 시점을 README에 표기
|
|
39
|
+
- **패키지 페이지 이미지 안정화** — README 이미지 URL을 GitHub raw URL로 전환해 PyPI / npm / Marketplace에서도 표시되도록 개선
|
|
40
|
+
- **npm 패키지 정리** — 배포 tarball에서 테스트/캐시 파일 제외
|
|
22
41
|
- **실제 UI 스크린샷 3장** — Chat UI · Admin Dashboard · Data Graph (Playwright 2x 캡처)
|
|
23
42
|
- **VS Code 익스텐션 카테고리** `Other` → `AI, Machine Learning, Chat, Other`
|
|
24
43
|
- **VS Code 익스텐션 키워드** 8개 → 16개 (copilot, apple-silicon, groq, graph-rag 등)
|
|
25
44
|
- **VS Code 익스텐션 README** 전면 재작성 (기능표, 비교표, 모델 목록)
|
|
26
45
|
- 구버전 `.tgz` / `.vsix` 빌드 파일 삭제
|
|
27
46
|
|
|
47
|
+
### CI / 보안 안정화
|
|
48
|
+
|
|
49
|
+
- `/local/list` `GET` smoke-test 호환 라우트 추가
|
|
50
|
+
- `/local/list`, `/local/read`, `/local/write`, `/local/serve`는 로컬 개발 모드에서도 로그인 세션을 요구하도록 강화
|
|
51
|
+
- GitHub Actions integration smoke test 실패 원인 수정
|
|
52
|
+
|
|
28
53
|
### Release
|
|
29
54
|
- 배포 버전을 `0.1.23`으로 상향
|
|
30
55
|
- 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"description": "Lattice AI local MLX/cloud LLM workspace server",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -50,7 +50,6 @@
|
|
|
50
50
|
"tools.py",
|
|
51
51
|
"codex_telegram_bot.py",
|
|
52
52
|
"skills/",
|
|
53
|
-
"tests/",
|
|
54
53
|
"static/account.html",
|
|
55
54
|
"static/chat.html",
|
|
56
55
|
"static/admin.html",
|
package/server.py
CHANGED
|
@@ -5631,6 +5631,13 @@ def _local_permission_response(path: str, action: str, user_email: str, content:
|
|
|
5631
5631
|
}
|
|
5632
5632
|
|
|
5633
5633
|
|
|
5634
|
+
def _require_local_user(request: Request) -> str:
|
|
5635
|
+
email = get_current_user(request)
|
|
5636
|
+
if not email:
|
|
5637
|
+
raise HTTPException(status_code=401, detail="로컬 파일 접근은 로그인 세션이 필요합니다.")
|
|
5638
|
+
return email
|
|
5639
|
+
|
|
5640
|
+
|
|
5634
5641
|
def _require_local_approval(
|
|
5635
5642
|
*,
|
|
5636
5643
|
token: Optional[str],
|
|
@@ -5773,16 +5780,22 @@ async def permissions_status(token: str, request: Request):
|
|
|
5773
5780
|
|
|
5774
5781
|
@app.post("/local/list")
|
|
5775
5782
|
async def local_list_endpoint(req: LocalAccessRequest, request: Request):
|
|
5776
|
-
current_user =
|
|
5783
|
+
current_user = _require_local_user(request)
|
|
5777
5784
|
if not req.approved:
|
|
5778
5785
|
return _local_permission_response(req.path, "list", current_user)
|
|
5779
5786
|
_require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
|
|
5780
5787
|
return _tool_response(local_list, req.path)
|
|
5781
5788
|
|
|
5782
5789
|
|
|
5790
|
+
@app.get("/local/list")
|
|
5791
|
+
async def local_list_get_endpoint(path: str, request: Request):
|
|
5792
|
+
current_user = _require_local_user(request)
|
|
5793
|
+
return _local_permission_response(path, "list", current_user)
|
|
5794
|
+
|
|
5795
|
+
|
|
5783
5796
|
@app.post("/local/read")
|
|
5784
5797
|
async def local_read_endpoint(req: LocalAccessRequest, request: Request):
|
|
5785
|
-
current_user =
|
|
5798
|
+
current_user = _require_local_user(request)
|
|
5786
5799
|
if not req.approved:
|
|
5787
5800
|
return _local_permission_response(req.path, "read", current_user)
|
|
5788
5801
|
_require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
|
|
@@ -5792,7 +5805,7 @@ async def local_read_endpoint(req: LocalAccessRequest, request: Request):
|
|
|
5792
5805
|
@app.get("/local/serve")
|
|
5793
5806
|
async def local_serve_file(path: str, request: Request, approval_token: Optional[str] = None):
|
|
5794
5807
|
"""Serve a local file (images etc.) directly for browser preview."""
|
|
5795
|
-
current_user =
|
|
5808
|
+
current_user = _require_local_user(request)
|
|
5796
5809
|
_require_local_approval(token=approval_token, path=path, action="read", user_email=current_user)
|
|
5797
5810
|
target = Path(path).expanduser().resolve()
|
|
5798
5811
|
if not target.exists() or not target.is_file():
|
|
@@ -5802,7 +5815,7 @@ async def local_serve_file(path: str, request: Request, approval_token: Optional
|
|
|
5802
5815
|
|
|
5803
5816
|
@app.post("/local/write")
|
|
5804
5817
|
async def local_write_endpoint(req: LocalWriteRequest, request: Request):
|
|
5805
|
-
current_user =
|
|
5818
|
+
current_user = _require_local_user(request)
|
|
5806
5819
|
if not req.approved:
|
|
5807
5820
|
return _local_permission_response(req.path, "write", current_user, req.content)
|
|
5808
5821
|
_require_local_approval(
|
package/tests/__init__.py
DELETED
|
File without changes
|
|
File without changes
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
"""Integration tests for FastAPI endpoints.
|
|
2
|
-
|
|
3
|
-
Run against a live server: pytest tests/integration/ --base-url http://localhost:8899
|
|
4
|
-
Default base URL falls back to http://localhost:8899 if flag not provided.
|
|
5
|
-
"""
|
|
6
|
-
import os
|
|
7
|
-
import pytest
|
|
8
|
-
import httpx
|
|
9
|
-
|
|
10
|
-
BASE_URL = os.environ.get("LTCAI_TEST_BASE_URL", "http://localhost:8899")
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _session_cookie() -> dict:
|
|
14
|
-
"""Return session cookie if LTCAI_TEST_SESSION env var is set, else empty dict."""
|
|
15
|
-
sid = os.environ.get("LTCAI_TEST_SESSION", "")
|
|
16
|
-
return {"session_id": sid} if sid else {}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@pytest.fixture(scope="session")
|
|
20
|
-
def client():
|
|
21
|
-
with httpx.Client(base_url=BASE_URL, cookies=_session_cookie(), timeout=15) as c:
|
|
22
|
-
yield c
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# ---------------------------------------------------------------------------
|
|
26
|
-
# Health / Info
|
|
27
|
-
# ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
def test_health(client):
|
|
30
|
-
r = client.get("/health")
|
|
31
|
-
assert r.status_code == 200
|
|
32
|
-
data = r.json()
|
|
33
|
-
assert "status" in data
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_mode_endpoint(client):
|
|
37
|
-
r = client.get("/mode")
|
|
38
|
-
assert r.status_code == 200
|
|
39
|
-
data = r.json()
|
|
40
|
-
assert "model" in data or "mode" in data
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_runtime_features(client):
|
|
44
|
-
r = client.get("/runtime_features")
|
|
45
|
-
assert r.status_code == 200
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# ---------------------------------------------------------------------------
|
|
49
|
-
# Local filesystem endpoints
|
|
50
|
-
# ---------------------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
def test_local_list_home(client):
|
|
53
|
-
r = client.get("/local/list", params={"path": "~"})
|
|
54
|
-
# 200 if authenticated, 401/403 if no session
|
|
55
|
-
assert r.status_code in (200, 401, 403)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_local_list_requires_auth(client):
|
|
59
|
-
"""Without a session cookie the endpoint must reject the request."""
|
|
60
|
-
r = httpx.get(f"{BASE_URL}/local/list", params={"path": "~"}, timeout=10)
|
|
61
|
-
assert r.status_code in (401, 403)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_local_serve_missing_file(client):
|
|
65
|
-
r = client.get("/local/serve", params={"path": "/nonexistent_lattice_ai_test_xyz.txt"})
|
|
66
|
-
assert r.status_code in (400, 401, 403, 404)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# ---------------------------------------------------------------------------
|
|
70
|
-
# /tools/read_document
|
|
71
|
-
# ---------------------------------------------------------------------------
|
|
72
|
-
|
|
73
|
-
def test_read_document_missing(client):
|
|
74
|
-
r = client.post("/tools/read_document", json={"path": "/nonexistent_lattice_ai_doc.txt"})
|
|
75
|
-
assert r.status_code in (400, 401, 403, 404, 422)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# ---------------------------------------------------------------------------
|
|
79
|
-
# /chat — smoke test (no model required)
|
|
80
|
-
# ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
def test_chat_requires_message(client):
|
|
83
|
-
r = client.post("/chat", json={})
|
|
84
|
-
# Missing message → 422 validation error or 401 unauth
|
|
85
|
-
assert r.status_code in (401, 403, 422)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# ---------------------------------------------------------------------------
|
|
89
|
-
# /agent — smoke test
|
|
90
|
-
# ---------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
def test_agent_requires_task(client):
|
|
93
|
-
r = client.post("/agent", json={})
|
|
94
|
-
assert r.status_code in (401, 403, 422)
|
package/tests/unit/__init__.py
DELETED
|
File without changes
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
"""Unit tests for security-sensitive helpers in server.py."""
|
|
2
|
-
import sys
|
|
3
|
-
import time
|
|
4
|
-
import pytest
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
8
|
-
|
|
9
|
-
from server import (
|
|
10
|
-
_bytes_match_extension,
|
|
11
|
-
_rate_buckets,
|
|
12
|
-
enforce_rate_limit,
|
|
13
|
-
hash_password,
|
|
14
|
-
verify_password,
|
|
15
|
-
_agent_risk,
|
|
16
|
-
_host_is_loopback,
|
|
17
|
-
_local_permission_response,
|
|
18
|
-
_require_local_approval,
|
|
19
|
-
_LOCAL_WRITE_BLOCKED_PREFIXES,
|
|
20
|
-
)
|
|
21
|
-
from fastapi import HTTPException
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# ---------------------------------------------------------------------------
|
|
25
|
-
# Password hashing
|
|
26
|
-
# ---------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
def test_password_hash_roundtrip():
|
|
29
|
-
h = hash_password("hunter2")
|
|
30
|
-
assert verify_password("hunter2", h)
|
|
31
|
-
assert not verify_password("wrong", h)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_password_hash_not_plaintext():
|
|
35
|
-
h = hash_password("hunter2")
|
|
36
|
-
assert "hunter2" not in h
|
|
37
|
-
assert ":" in h # salt:hash format
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_password_hash_unique_per_call():
|
|
41
|
-
"""Same input must yield different hashes (salted)."""
|
|
42
|
-
h1 = hash_password("same")
|
|
43
|
-
h2 = hash_password("same")
|
|
44
|
-
assert h1 != h2
|
|
45
|
-
assert verify_password("same", h1)
|
|
46
|
-
assert verify_password("same", h2)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# ---------------------------------------------------------------------------
|
|
50
|
-
# MIME / magic-number sniffing
|
|
51
|
-
# ---------------------------------------------------------------------------
|
|
52
|
-
|
|
53
|
-
def test_bytes_match_pdf():
|
|
54
|
-
assert _bytes_match_extension(b"%PDF-1.7\n...", ".pdf")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_bytes_match_pdf_rejects_zip_bytes():
|
|
58
|
-
assert not _bytes_match_extension(b"PK\x03\x04...", ".pdf")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_bytes_match_docx_is_zip():
|
|
62
|
-
assert _bytes_match_extension(b"PK\x03\x04...", ".docx")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_bytes_match_png():
|
|
66
|
-
assert _bytes_match_extension(b"\x89PNG\r\n\x1a\nrest", ".png")
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_bytes_match_txt_skips_check():
|
|
70
|
-
"""Text-like formats have no magic — always accepted."""
|
|
71
|
-
assert _bytes_match_extension(b"anything goes", ".txt")
|
|
72
|
-
assert _bytes_match_extension(b"anything goes", ".md")
|
|
73
|
-
assert _bytes_match_extension(b"anything goes", ".csv")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# ---------------------------------------------------------------------------
|
|
77
|
-
# Rate limiting
|
|
78
|
-
# ---------------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
def test_rate_limit_allows_within_capacity():
|
|
81
|
-
_rate_buckets.clear()
|
|
82
|
-
for _ in range(10):
|
|
83
|
-
enforce_rate_limit("test_user@example.com", "agent") # capacity 10
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_rate_limit_blocks_over_capacity():
|
|
87
|
-
_rate_buckets.clear()
|
|
88
|
-
for _ in range(10):
|
|
89
|
-
enforce_rate_limit("burst_user@example.com", "agent")
|
|
90
|
-
with pytest.raises(HTTPException) as exc:
|
|
91
|
-
enforce_rate_limit("burst_user@example.com", "agent")
|
|
92
|
-
assert exc.value.status_code == 429
|
|
93
|
-
assert "Retry-After" in exc.value.headers
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def test_rate_limit_skips_unauth():
|
|
97
|
-
"""Empty email = no rate-limit (anon health-check style)."""
|
|
98
|
-
_rate_buckets.clear()
|
|
99
|
-
for _ in range(200):
|
|
100
|
-
enforce_rate_limit("", "agent") # never raises
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# ---------------------------------------------------------------------------
|
|
104
|
-
# Harness risk classification
|
|
105
|
-
# ---------------------------------------------------------------------------
|
|
106
|
-
|
|
107
|
-
def test_agent_risk_read_only_is_low():
|
|
108
|
-
assert _agent_risk("local_read", {"path": "/tmp/x"}) == "low"
|
|
109
|
-
assert _agent_risk("list_dir", {}) == "low"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def test_agent_risk_write_is_medium():
|
|
113
|
-
assert _agent_risk("write_file", {"path": "out.txt"}) == "medium"
|
|
114
|
-
assert _agent_risk("local_write", {"path": "/tmp/safe.txt"}) == "medium"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def test_agent_risk_run_command_is_high():
|
|
118
|
-
assert _agent_risk("run_command", {"command": "ls"}) == "high"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def test_agent_risk_system_path_write_upgraded_to_high():
|
|
122
|
-
for prefix in _LOCAL_WRITE_BLOCKED_PREFIXES:
|
|
123
|
-
risk = _agent_risk("local_write", {"path": prefix + "evil.txt"})
|
|
124
|
-
assert risk == "high", f"prefix {prefix} should upgrade local_write to high"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def test_agent_risk_unknown_action_defaults_medium():
|
|
128
|
-
assert _agent_risk("nonexistent_tool_xyz", {}) == "medium"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
# ---------------------------------------------------------------------------
|
|
132
|
-
# Network exposure / local file approvals
|
|
133
|
-
# ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
def test_host_loopback_detection():
|
|
136
|
-
assert _host_is_loopback("127.0.0.1")
|
|
137
|
-
assert _host_is_loopback("localhost")
|
|
138
|
-
assert not _host_is_loopback("0.0.0.0")
|
|
139
|
-
assert not _host_is_loopback("192.168.0.2")
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def test_local_approval_token_allows_exact_scope(tmp_path):
|
|
143
|
-
target = tmp_path / "note.txt"
|
|
144
|
-
target.write_text("hello")
|
|
145
|
-
user = "alice@example.com"
|
|
146
|
-
approval = _local_permission_response(str(target), "read", user)
|
|
147
|
-
from server import _local_approvals
|
|
148
|
-
_local_approvals[approval["approval_token"]]["approved"] = True
|
|
149
|
-
|
|
150
|
-
_require_local_approval(
|
|
151
|
-
token=approval["approval_token"],
|
|
152
|
-
path=str(target),
|
|
153
|
-
action="read",
|
|
154
|
-
user_email=user,
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def test_local_approval_token_rejects_wrong_path(tmp_path):
|
|
159
|
-
allowed = tmp_path / "allowed.txt"
|
|
160
|
-
denied = tmp_path / "denied.txt"
|
|
161
|
-
allowed.write_text("allowed")
|
|
162
|
-
denied.write_text("denied")
|
|
163
|
-
user = "alice@example.com"
|
|
164
|
-
approval = _local_permission_response(str(allowed), "read", user)
|
|
165
|
-
from server import _local_approvals
|
|
166
|
-
_local_approvals[approval["approval_token"]]["approved"] = True
|
|
167
|
-
|
|
168
|
-
with pytest.raises(HTTPException) as exc:
|
|
169
|
-
_require_local_approval(
|
|
170
|
-
token=approval["approval_token"],
|
|
171
|
-
path=str(denied),
|
|
172
|
-
action="read",
|
|
173
|
-
user_email=user,
|
|
174
|
-
)
|
|
175
|
-
assert exc.value.status_code == 403
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def test_local_write_approval_binds_content(tmp_path):
|
|
179
|
-
target = tmp_path / "out.txt"
|
|
180
|
-
user = "alice@example.com"
|
|
181
|
-
approval = _local_permission_response(str(target), "write", user, "first")
|
|
182
|
-
from server import _local_approvals
|
|
183
|
-
_local_approvals[approval["approval_token"]]["approved"] = True
|
|
184
|
-
|
|
185
|
-
with pytest.raises(HTTPException) as exc:
|
|
186
|
-
_require_local_approval(
|
|
187
|
-
token=approval["approval_token"],
|
|
188
|
-
path=str(target),
|
|
189
|
-
action="write",
|
|
190
|
-
user_email=user,
|
|
191
|
-
content="changed",
|
|
192
|
-
)
|
|
193
|
-
assert exc.value.status_code == 403
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import shutil
|
|
3
|
-
|
|
4
|
-
import setup
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def test_scan_environment_includes_components_and_paths():
|
|
8
|
-
env = setup.scan_environment()
|
|
9
|
-
|
|
10
|
-
assert "components" in env
|
|
11
|
-
assert "python" in env["components"]
|
|
12
|
-
assert "official_url" in env["components"]["python"]
|
|
13
|
-
assert "path" in env
|
|
14
|
-
assert "active" in env["path"]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_recommendations_include_components():
|
|
18
|
-
env = setup.scan_environment()
|
|
19
|
-
recs = setup.get_recommendations(env)
|
|
20
|
-
|
|
21
|
-
assert "components" in recs
|
|
22
|
-
assert any(item["id"].startswith("component_") for item in recs["components"])
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_repair_path_can_find_binary_from_common_dir(monkeypatch):
|
|
26
|
-
python_path = shutil.which("python3") or shutil.which("python")
|
|
27
|
-
assert python_path
|
|
28
|
-
python_dir = os.path.dirname(python_path)
|
|
29
|
-
|
|
30
|
-
monkeypatch.setattr(setup, "COMMON_PATH_DIRS", [python_dir])
|
|
31
|
-
monkeypatch.setenv("PATH", "")
|
|
32
|
-
|
|
33
|
-
setup.repair_path_for("python3" if python_path.endswith("python3") else "python")
|
|
34
|
-
|
|
35
|
-
assert python_dir in os.environ["PATH"].split(os.pathsep)
|
package/tests/unit/test_tools.py
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
"""Unit tests for tools.py core functions."""
|
|
2
|
-
import sys
|
|
3
|
-
import pytest
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
7
|
-
|
|
8
|
-
import tools as tools_module
|
|
9
|
-
from tools import (
|
|
10
|
-
ToolError,
|
|
11
|
-
edit_file,
|
|
12
|
-
grep,
|
|
13
|
-
local_list,
|
|
14
|
-
local_read,
|
|
15
|
-
local_write,
|
|
16
|
-
read_document,
|
|
17
|
-
read_file,
|
|
18
|
-
todo_read,
|
|
19
|
-
todo_write,
|
|
20
|
-
write_file,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@pytest.fixture
|
|
25
|
-
def workspace(tmp_path, monkeypatch):
|
|
26
|
-
"""Redirect tools' AGENT_ROOT to a temp directory for the duration of a test."""
|
|
27
|
-
monkeypatch.setattr(tools_module, "AGENT_ROOT", tmp_path)
|
|
28
|
-
tools_module.ensure_agent_root()
|
|
29
|
-
return tmp_path
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# ---------------------------------------------------------------------------
|
|
33
|
-
# local_list
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
def test_local_list_returns_items(tmp_path):
|
|
37
|
-
(tmp_path / "a.txt").write_text("hello")
|
|
38
|
-
(tmp_path / "sub").mkdir()
|
|
39
|
-
result = local_list(str(tmp_path))
|
|
40
|
-
names = [i["name"] for i in result["items"]]
|
|
41
|
-
assert "a.txt" in names
|
|
42
|
-
assert "sub" in names
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_local_list_dirs_before_files(tmp_path):
|
|
46
|
-
(tmp_path / "z_file.txt").write_text("x")
|
|
47
|
-
(tmp_path / "a_dir").mkdir()
|
|
48
|
-
result = local_list(str(tmp_path))
|
|
49
|
-
types = [i["type"] for i in result["items"]]
|
|
50
|
-
assert types[0] == "directory"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def test_local_list_nonexistent_raises(tmp_path):
|
|
54
|
-
with pytest.raises(ToolError):
|
|
55
|
-
local_list(str(tmp_path / "does_not_exist"))
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_local_list_file_raises(tmp_path):
|
|
59
|
-
f = tmp_path / "file.txt"
|
|
60
|
-
f.write_text("x")
|
|
61
|
-
with pytest.raises(ToolError):
|
|
62
|
-
local_list(str(f))
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# ---------------------------------------------------------------------------
|
|
66
|
-
# local_read
|
|
67
|
-
# ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
def test_local_read_text_file(tmp_path):
|
|
70
|
-
f = tmp_path / "hello.txt"
|
|
71
|
-
f.write_text("hello world")
|
|
72
|
-
result = local_read(str(f))
|
|
73
|
-
assert "hello world" in result["content"]
|
|
74
|
-
assert result["path"] == str(f)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def test_local_read_missing_file_raises(tmp_path):
|
|
78
|
-
with pytest.raises(ToolError):
|
|
79
|
-
local_read(str(tmp_path / "missing.txt"))
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_local_read_tilde_expansion(tmp_path, monkeypatch):
|
|
83
|
-
monkeypatch.setenv("HOME", str(tmp_path))
|
|
84
|
-
f = tmp_path / "testfile.txt"
|
|
85
|
-
f.write_text("tilde test")
|
|
86
|
-
result = local_read("~/testfile.txt")
|
|
87
|
-
assert "tilde test" in result["content"]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def test_local_read_returns_size(tmp_path):
|
|
91
|
-
f = tmp_path / "sized.txt"
|
|
92
|
-
f.write_text("abc")
|
|
93
|
-
result = local_read(str(f))
|
|
94
|
-
assert result["size"] == 3
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# ---------------------------------------------------------------------------
|
|
98
|
-
# local_write
|
|
99
|
-
# ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
def test_local_write_creates_file(tmp_path):
|
|
102
|
-
target = tmp_path / "out.txt"
|
|
103
|
-
result = local_write(str(target), "new content")
|
|
104
|
-
assert target.read_text() == "new content"
|
|
105
|
-
assert "bytes" in result
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def test_local_write_overwrites_file(tmp_path):
|
|
109
|
-
target = tmp_path / "out.txt"
|
|
110
|
-
target.write_text("old")
|
|
111
|
-
local_write(str(target), "new")
|
|
112
|
-
assert target.read_text() == "new"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_local_write_creates_parent_dirs(tmp_path):
|
|
116
|
-
target = tmp_path / "deep" / "nested" / "file.txt"
|
|
117
|
-
local_write(str(target), "deep write")
|
|
118
|
-
assert target.exists()
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def test_local_write_returns_path(tmp_path):
|
|
122
|
-
target = tmp_path / "x.txt"
|
|
123
|
-
result = local_write(str(target), "hi")
|
|
124
|
-
assert result["path"] == str(target)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
# ---------------------------------------------------------------------------
|
|
128
|
-
# read_document
|
|
129
|
-
# ---------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
def test_read_document_plain_text(tmp_path):
|
|
132
|
-
f = tmp_path / "doc.txt"
|
|
133
|
-
f.write_text("plain text content")
|
|
134
|
-
result = read_document(str(f))
|
|
135
|
-
assert "plain text content" in result.get("text", "") or "plain text content" in str(result)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def test_read_document_missing_file_raises(tmp_path):
|
|
139
|
-
with pytest.raises(ToolError):
|
|
140
|
-
read_document(str(tmp_path / "missing.pdf"))
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def test_read_document_csv(tmp_path):
|
|
144
|
-
f = tmp_path / "data.csv"
|
|
145
|
-
f.write_text("col1,col2\n1,2\n3,4\n")
|
|
146
|
-
result = read_document(str(f))
|
|
147
|
-
# should not raise; returns some text content
|
|
148
|
-
assert result is not None
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# ---------------------------------------------------------------------------
|
|
152
|
-
# read_file (workspace, with line numbers and offset/limit)
|
|
153
|
-
# ---------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
def test_read_file_returns_numbered_view(workspace):
|
|
156
|
-
(workspace / "a.txt").write_text("alpha\nbeta\ngamma\n")
|
|
157
|
-
result = read_file("a.txt")
|
|
158
|
-
assert result["total_lines"] == 3
|
|
159
|
-
assert result["start_line"] == 1
|
|
160
|
-
assert result["end_line"] == 3
|
|
161
|
-
assert "1\talpha" in result["numbered"]
|
|
162
|
-
assert "3\tgamma" in result["numbered"]
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def test_read_file_offset_and_limit(workspace):
|
|
166
|
-
(workspace / "a.txt").write_text("\n".join(f"line{i}" for i in range(1, 11)))
|
|
167
|
-
result = read_file("a.txt", offset=3, limit=2)
|
|
168
|
-
assert result["start_line"] == 4
|
|
169
|
-
assert result["end_line"] == 5
|
|
170
|
-
assert result["content"].splitlines() == ["line4", "line5"]
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def test_read_file_disable_line_numbers(workspace):
|
|
174
|
-
(workspace / "a.txt").write_text("only\n")
|
|
175
|
-
result = read_file("a.txt", line_numbers=False)
|
|
176
|
-
assert "numbered" not in result
|
|
177
|
-
assert result["content"] == "only\n"
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
# ---------------------------------------------------------------------------
|
|
181
|
-
# edit_file
|
|
182
|
-
# ---------------------------------------------------------------------------
|
|
183
|
-
|
|
184
|
-
def test_edit_file_replaces_unique_match(workspace):
|
|
185
|
-
(workspace / "code.py").write_text("def foo():\n return 1\n")
|
|
186
|
-
result = edit_file("code.py", "return 1", "return 42")
|
|
187
|
-
assert (workspace / "code.py").read_text() == "def foo():\n return 42\n"
|
|
188
|
-
assert result["replacements"] == 1
|
|
189
|
-
assert result["first_edit_line"] == 2
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def test_edit_file_missing_string_raises(workspace):
|
|
193
|
-
(workspace / "code.py").write_text("hello\n")
|
|
194
|
-
with pytest.raises(ToolError, match="not found"):
|
|
195
|
-
edit_file("code.py", "missing", "world")
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def test_edit_file_ambiguous_raises_unless_replace_all(workspace):
|
|
199
|
-
(workspace / "code.py").write_text("x = 1\nx = 1\n")
|
|
200
|
-
with pytest.raises(ToolError, match="ambiguous"):
|
|
201
|
-
edit_file("code.py", "x = 1", "x = 2")
|
|
202
|
-
result = edit_file("code.py", "x = 1", "x = 2", replace_all=True)
|
|
203
|
-
assert result["replacements"] == 2
|
|
204
|
-
assert (workspace / "code.py").read_text() == "x = 2\nx = 2\n"
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def test_edit_file_rejects_identical(workspace):
|
|
208
|
-
(workspace / "code.py").write_text("same\n")
|
|
209
|
-
with pytest.raises(ToolError, match="identical"):
|
|
210
|
-
edit_file("code.py", "same", "same")
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
# ---------------------------------------------------------------------------
|
|
214
|
-
# grep
|
|
215
|
-
# ---------------------------------------------------------------------------
|
|
216
|
-
|
|
217
|
-
def test_grep_finds_regex_matches(workspace):
|
|
218
|
-
(workspace / "a.py").write_text("def foo():\n return 1\n\ndef bar():\n return 2\n")
|
|
219
|
-
(workspace / "b.py").write_text("x = 1\n")
|
|
220
|
-
result = grep(r"^def \w+", path=".")
|
|
221
|
-
paths = sorted({m["path"] for m in result["matches"]})
|
|
222
|
-
assert "a.py" in paths
|
|
223
|
-
assert result["files_with_matches"] == 1
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def test_grep_respects_glob(workspace):
|
|
227
|
-
(workspace / "a.py").write_text("needle\n")
|
|
228
|
-
(workspace / "a.txt").write_text("needle\n")
|
|
229
|
-
result = grep("needle", path=".", glob="*.py")
|
|
230
|
-
paths = [m["path"] for m in result["matches"]]
|
|
231
|
-
assert "a.py" in paths
|
|
232
|
-
assert "a.txt" not in paths
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def test_grep_case_insensitive(workspace):
|
|
236
|
-
(workspace / "a.txt").write_text("HELLO world\n")
|
|
237
|
-
result = grep("hello", path=".", case_insensitive=True)
|
|
238
|
-
assert any("HELLO" in m["match"] for m in result["matches"])
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def test_grep_context_lines(workspace):
|
|
242
|
-
(workspace / "a.txt").write_text("before\nhit\nafter\n")
|
|
243
|
-
result = grep("hit", path=".", context_lines=1)
|
|
244
|
-
assert result["matches"]
|
|
245
|
-
ctx_lines = [c["text"] for c in result["matches"][0]["context"]]
|
|
246
|
-
assert "before" in ctx_lines and "after" in ctx_lines
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def test_grep_invalid_regex_raises(workspace):
|
|
250
|
-
(workspace / "a.txt").write_text("hello\n")
|
|
251
|
-
with pytest.raises(ToolError, match="regex"):
|
|
252
|
-
grep("[unterminated", path=".")
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def test_grep_skips_binary_dirs(workspace):
|
|
256
|
-
(workspace / "node_modules").mkdir()
|
|
257
|
-
(workspace / "node_modules" / "x.js").write_text("needle\n")
|
|
258
|
-
(workspace / "src.py").write_text("needle\n")
|
|
259
|
-
result = grep("needle", path=".")
|
|
260
|
-
paths = [m["path"] for m in result["matches"]]
|
|
261
|
-
assert "src.py" in paths
|
|
262
|
-
assert all("node_modules" not in p for p in paths)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
# ---------------------------------------------------------------------------
|
|
266
|
-
# todo_read / todo_write
|
|
267
|
-
# ---------------------------------------------------------------------------
|
|
268
|
-
|
|
269
|
-
def test_todo_read_empty_when_unset(workspace):
|
|
270
|
-
result = todo_read()
|
|
271
|
-
assert result["todos"] == []
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def test_todo_write_round_trip(workspace):
|
|
275
|
-
todos = [
|
|
276
|
-
{"id": "1", "content": "design API", "status": "completed"},
|
|
277
|
-
{"id": "2", "content": "write tests", "status": "in_progress"},
|
|
278
|
-
{"id": "3", "content": "deploy", "status": "pending"},
|
|
279
|
-
]
|
|
280
|
-
todo_write(todos)
|
|
281
|
-
fresh = todo_read()
|
|
282
|
-
assert [t["content"] for t in fresh["todos"]] == ["design API", "write tests", "deploy"]
|
|
283
|
-
assert fresh["todos"][1]["status"] == "in_progress"
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def test_todo_write_rejects_invalid_status(workspace):
|
|
287
|
-
with pytest.raises(ToolError, match="status"):
|
|
288
|
-
todo_write([{"id": "1", "content": "x", "status": "blocked"}])
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def test_todo_write_rejects_missing_content(workspace):
|
|
292
|
-
with pytest.raises(ToolError, match="content"):
|
|
293
|
-
todo_write([{"id": "1", "content": "", "status": "pending"}])
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def test_todo_write_warns_multiple_in_progress(workspace):
|
|
297
|
-
result = todo_write([
|
|
298
|
-
{"id": "1", "content": "a", "status": "in_progress"},
|
|
299
|
-
{"id": "2", "content": "b", "status": "in_progress"},
|
|
300
|
-
])
|
|
301
|
-
assert result["warning"]
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
# ---------------------------------------------------------------------------
|
|
305
|
-
# Sandbox: workspace tools must not escape AGENT_ROOT
|
|
306
|
-
# ---------------------------------------------------------------------------
|
|
307
|
-
|
|
308
|
-
def test_read_file_blocks_path_escape(workspace):
|
|
309
|
-
with pytest.raises(ToolError, match="escapes"):
|
|
310
|
-
read_file("../../etc/passwd")
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def test_edit_file_blocks_path_escape(workspace):
|
|
314
|
-
with pytest.raises(ToolError, match="escapes"):
|
|
315
|
-
edit_file("../../etc/passwd", "a", "b")
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def test_grep_blocks_path_escape(workspace):
|
|
319
|
-
with pytest.raises(ToolError, match="escapes"):
|
|
320
|
-
grep("x", path="../../etc")
|