ltcai 0.1.22 → 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 +227 -256
- package/docs/CHANGELOG.md +79 -1
- package/docs/images/logo.svg +33 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -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/docs/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,92 @@
|
|
|
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
|
+
|
|
18
|
+
## [0.1.23] - 2026-05-24
|
|
19
|
+
|
|
20
|
+
### Discord 권한 알림 시스템
|
|
21
|
+
|
|
22
|
+
- **`GET /permissions/pending`** — 대기 중인 파일 접근 권한 요청 목록 (관리자)
|
|
23
|
+
- **`POST /permissions/approve/{token}`** — 권한 승인 (관리자 세션 또는 `LATTICEAI_PERMISSION_SECRET`)
|
|
24
|
+
- **`POST /permissions/deny/{token}`** — 권한 거부/취소
|
|
25
|
+
- **`GET /permissions/status/{token}`** — 승인 상태 폴링 (AI 에이전트용)
|
|
26
|
+
- 권한 토큰 기본값 `approved: False` — 명시적 승인 전까지 파일 접근 불가
|
|
27
|
+
- `~/.ltcai/permission_queue.json` — 서버가 기록, Claude Code Discord 플러그인이 읽어 알림 전송
|
|
28
|
+
- `LATTICEAI_PERMISSION_SECRET` 환경변수 — 모니터 스크립트가 세션 없이 approve/deny 호출 가능
|
|
29
|
+
- `perm_monitor.py` — 권한 목록 조회·승인·거부 CLI 도우미 (`list` / `approve TOKEN` / `deny TOKEN` / `discord-msg`)
|
|
30
|
+
- Discord에서 `승인 <토큰앞8자>` / `거부 <토큰앞8자>` 로 파일 접근 제어 가능
|
|
31
|
+
|
|
32
|
+
### 리포지터리 UX 개선
|
|
33
|
+
|
|
34
|
+
- **영어 README** 전면 재작성 — 한국어는 접을 수 있는 `<details>` 섹션으로 이동
|
|
35
|
+
- **SVG 로고** 추가 (`docs/images/logo.svg`)
|
|
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에서 테스트/캐시 파일 제외
|
|
41
|
+
- **실제 UI 스크린샷 3장** — Chat UI · Admin Dashboard · Data Graph (Playwright 2x 캡처)
|
|
42
|
+
- **VS Code 익스텐션 카테고리** `Other` → `AI, Machine Learning, Chat, Other`
|
|
43
|
+
- **VS Code 익스텐션 키워드** 8개 → 16개 (copilot, apple-silicon, groq, graph-rag 등)
|
|
44
|
+
- **VS Code 익스텐션 README** 전면 재작성 (기능표, 비교표, 모델 목록)
|
|
45
|
+
- 구버전 `.tgz` / `.vsix` 빌드 파일 삭제
|
|
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
|
+
|
|
53
|
+
### Release
|
|
54
|
+
- 배포 버전을 `0.1.23`으로 상향
|
|
55
|
+
- 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
|
|
56
|
+
|
|
3
57
|
## [0.1.22] - 2026-05-24
|
|
4
58
|
|
|
59
|
+
### 리포지터리 UX 개선 — 다운로드 유입 최적화
|
|
60
|
+
|
|
61
|
+
#### README 전면 재작성
|
|
62
|
+
- **영어 메인 문서** — 한국어는 접을 수 있는 `<details>` 섹션으로 이동 (국제 유입 대응)
|
|
63
|
+
- **SVG 로고 추가** (`docs/images/logo.svg`) — 인디고→시안 그라디언트 래티스 그리드 아이콘
|
|
64
|
+
- **경쟁 제품 비교표** — Lattice AI vs Open WebUI · Continue.dev · GitHub Copilot 10개 기준 비교
|
|
65
|
+
- **PyPI 월간 다운로드 수 배지** 추가 (신뢰도 지표)
|
|
66
|
+
- 기능 · 보안 · API · 트러블슈팅 섹션을 표(table) 형식으로 정리 (가독성 향상)
|
|
67
|
+
|
|
68
|
+
#### 실제 UI 스크린샷 자동 캡처
|
|
69
|
+
- `docs/images/screenshot-chat.png` — 웹 채팅 UI (사이드바, 모델/파이프라인/VPC 카드)
|
|
70
|
+
- `docs/images/screenshot-admin.png` — 어드민 대시보드 + Audit & Data Governance 섹션
|
|
71
|
+
- `docs/images/screenshot-graph.png` — Data Graph 시각화 (299 노드, 443 엣지)
|
|
72
|
+
- README 상단에 3단 그리드 스크린샷 테이블 추가
|
|
73
|
+
- `scripts/take_screenshots.js` — Playwright Chromium 헤드리스 캡처 스크립트 (2x 레티나)
|
|
74
|
+
|
|
75
|
+
#### VS Code 익스텐션 메타데이터 개선
|
|
76
|
+
- **카테고리** `Other` → `AI, Machine Learning, Chat, Other` (Marketplace 검색 노출 증가)
|
|
77
|
+
- **키워드** 8개 → 16개 추가 (`copilot`, `apple-silicon`, `groq`, `graph-rag` 등)
|
|
78
|
+
- **설명 문구** 구체화 — 핵심 차별점(MLX, MCP, Graph RAG, zero telemetry) 명시
|
|
79
|
+
- **익스텐션 README 전면 재작성** — 기능표 · 빠른 시작 · 단축키 · 지원 모델 · 설정 · 비교표 포함
|
|
80
|
+
|
|
81
|
+
#### 리포지터리 정리
|
|
82
|
+
- 루트 및 `vscode-extension/`의 구버전 `.tgz` / `.vsix` 빌드 파일 삭제
|
|
83
|
+
|
|
5
84
|
### Release preparation
|
|
6
85
|
|
|
7
86
|
- 배포 버전을 `0.1.22`로 상향
|
|
8
87
|
- `package.json`
|
|
9
88
|
- `pyproject.toml`
|
|
10
89
|
- `vscode-extension/package.json`
|
|
11
|
-
- README 현재 배포 버전과 릴리스 체크 섹션을 `0.1.22` 기준으로 갱신
|
|
12
90
|
- npm / PyPI / VS Code Marketplace / Open VSX 배포 전 빌드 산출물 생성
|
|
13
91
|
|
|
14
92
|
### Verification
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 60" width="240" height="60">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" style="stop-color:#6366f1"/>
|
|
5
|
+
<stop offset="100%" style="stop-color:#06b6d4"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<!-- Lattice grid icon -->
|
|
9
|
+
<g transform="translate(8,10)">
|
|
10
|
+
<!-- dots -->
|
|
11
|
+
<circle cx="0" cy="0" r="3" fill="url(#g)" opacity="0.9"/>
|
|
12
|
+
<circle cx="16" cy="0" r="3" fill="url(#g)" opacity="0.9"/>
|
|
13
|
+
<circle cx="32" cy="0" r="3" fill="url(#g)" opacity="0.9"/>
|
|
14
|
+
<circle cx="0" cy="16" r="3" fill="url(#g)" opacity="0.9"/>
|
|
15
|
+
<circle cx="16" cy="16" r="4" fill="url(#g)"/>
|
|
16
|
+
<circle cx="32" cy="16" r="3" fill="url(#g)" opacity="0.9"/>
|
|
17
|
+
<circle cx="0" cy="32" r="3" fill="url(#g)" opacity="0.9"/>
|
|
18
|
+
<circle cx="16" cy="32" r="3" fill="url(#g)" opacity="0.9"/>
|
|
19
|
+
<circle cx="32" cy="32" r="3" fill="url(#g)" opacity="0.9"/>
|
|
20
|
+
<!-- lines -->
|
|
21
|
+
<line x1="0" y1="0" x2="32" y2="0" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
|
|
22
|
+
<line x1="0" y1="16" x2="32" y2="16" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
|
|
23
|
+
<line x1="0" y1="32" x2="32" y2="32" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
|
|
24
|
+
<line x1="0" y1="0" x2="0" y2="32" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
|
|
25
|
+
<line x1="16" y1="0" x2="16" y2="32" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
|
|
26
|
+
<line x1="32" y1="0" x2="32" y2="32" stroke="url(#g)" stroke-width="1.2" opacity="0.4"/>
|
|
27
|
+
<!-- diagonals -->
|
|
28
|
+
<line x1="0" y1="0" x2="32" y2="32" stroke="url(#g)" stroke-width="1" opacity="0.25"/>
|
|
29
|
+
<line x1="32" y1="0" x2="0" y2="32" stroke="url(#g)" stroke-width="1" opacity="0.25"/>
|
|
30
|
+
</g>
|
|
31
|
+
<!-- Text -->
|
|
32
|
+
<text x="58" y="34" font-family="'SF Pro Display','Segoe UI',system-ui,sans-serif" font-size="26" font-weight="700" fill="url(#g)" letter-spacing="-0.5">Lattice AI</text>
|
|
33
|
+
</svg>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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)
|