ltcai 0.1.9 → 0.1.11

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.
Files changed (36) hide show
  1. package/README.md +141 -313
  2. package/docs/CHANGELOG.md +227 -0
  3. package/docs/architecture.md +121 -0
  4. package/docs/mcp-tools.md +116 -0
  5. package/docs/privacy.md +74 -0
  6. package/docs/public-deploy.md +137 -0
  7. package/docs/security-model.md +121 -0
  8. package/ltcai_cli.py +2 -2
  9. package/package.json +1 -1
  10. package/server.py +997 -261
  11. package/skills/SKILL_TEMPLATE.md +61 -29
  12. package/skills/code_review/SKILL.md +28 -0
  13. package/skills/code_review/examples.md +59 -0
  14. package/skills/code_review/risk.json +9 -0
  15. package/skills/code_review/schema.json +65 -0
  16. package/skills/data_analysis/SKILL.md +28 -0
  17. package/skills/data_analysis/examples.md +62 -0
  18. package/skills/data_analysis/risk.json +9 -0
  19. package/skills/data_analysis/schema.json +61 -0
  20. package/skills/file_edit/SKILL.md +33 -0
  21. package/skills/file_edit/examples.md +45 -0
  22. package/skills/file_edit/risk.json +9 -0
  23. package/skills/file_edit/schema.json +60 -0
  24. package/skills/summarize_document/SKILL.md +68 -0
  25. package/skills/summarize_document/examples.md +65 -0
  26. package/skills/summarize_document/risk.json +9 -0
  27. package/skills/summarize_document/schema.json +71 -0
  28. package/skills/web_search/SKILL.md +28 -0
  29. package/skills/web_search/examples.md +61 -0
  30. package/skills/web_search/risk.json +9 -0
  31. package/skills/web_search/schema.json +62 -0
  32. package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
  33. package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
  34. package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
  35. package/tests/unit/test_tools.py +194 -1
  36. package/tools.py +264 -4
@@ -0,0 +1,68 @@
1
+ # Skill: summarize_document
2
+
3
+ ## 메타데이터
4
+ - **버전**: 0.1.0
5
+ - **카테고리**: document
6
+ - **위험도**: low
7
+ - **필요 권한**: local_read
8
+
9
+ ## 설명
10
+ 텍스트 파일(.txt, .md, .pdf, .docx)을 읽어 핵심 내용을 요약하고, 섹션별 요점과 키워드를 추출한다.
11
+
12
+ ## 거버넌스
13
+
14
+ `risk.json` 참고. 요약: `risk=read`, `destructive=false`, `shell=false`, `network=false`, `auto_approve=true`, `sandbox=home`, `rollback=none`
15
+
16
+ ## 트리거 조건
17
+
18
+ 호출해야 하는 상황:
19
+ - 사용자가 "이 문서 요약해줘", "핵심만 뽑아줘", "이 파일 내용이 뭐야?"라고 요청할 때
20
+ - 에이전트가 Discover 단계에서 긴 문서의 구조를 빠르게 파악해야 할 때
21
+ - 여러 문서를 비교하기 전 각 문서의 핵심 파악이 필요할 때
22
+
23
+ 호출하면 **안** 되는 상황:
24
+ - 문서를 수정해야 할 때 → `edit_file` 사용
25
+ - CSV/Excel 데이터 분석 → `data_analysis` 사용
26
+ - 코드 파일 분석 → `code_review` 사용
27
+
28
+ ## Side Effects
29
+
30
+ | 항목 | 내용 |
31
+ |------|------|
32
+ | 파일 변경 | 없음 |
33
+ | 생성 파일 | 없음 |
34
+ | 프로세스 | 없음 |
35
+ | 네트워크 | 없음 |
36
+
37
+ ## Rollback
38
+
39
+ 없음. 읽기 전용 작업.
40
+
41
+ ## 입력 스키마
42
+ `schema.json` 참고.
43
+
44
+ ## 출력 스키마
45
+ `schema.json` 참고.
46
+
47
+ ## 실행 조건
48
+ - LLM 모델이 로드되어 있어야 함
49
+ - 파일이 존재해야 하며 .txt/.md/.pdf/.docx 형식이어야 함
50
+ - 파일 크기 20 MB 이하
51
+
52
+ ## 예제
53
+ `examples.md` 참고.
54
+
55
+ ## 실패 처리
56
+ | 에러 코드 | 원인 | 처리 방법 |
57
+ |-----------|------|-----------|
58
+ | `INVALID_INPUT` | path 누락 | 파일 경로 입력 |
59
+ | `FILE_NOT_FOUND` | 경로에 파일 없음 | 경로 확인 |
60
+ | `UNSUPPORTED_FORMAT` | 지원하지 않는 형식 | .txt/.md/.pdf/.docx로 변환 후 재시도 |
61
+ | `SIZE_LIMIT` | 20 MB 초과 | 파일 분할 후 재시도 |
62
+ | `MODEL_NOT_LOADED` | LLM 미로드 | `/model` 명령으로 모델 선택 |
63
+
64
+ ## 테스트 케이스
65
+ ```python
66
+ # tests/unit/test_tools.py::test_summarize_document_md
67
+ # tests/unit/test_tools.py::test_summarize_document_unsupported
68
+ ```
@@ -0,0 +1,65 @@
1
+ # summarize_document — Examples
2
+
3
+ ## 1. Markdown file summary (success)
4
+
5
+ **Input**
6
+ ```json
7
+ { "path": "~/project/README.md", "style": "bullet" }
8
+ ```
9
+ **Output**
10
+ ```json
11
+ {
12
+ "success": true,
13
+ "result": {
14
+ "title": "README.md",
15
+ "summary": "• Lattice AI는 Apple Silicon 기반 로컬 AI 에이전트\n• FastAPI 서버 + VS Code 익스텐션 + Telegram bot 구조\n• MLX 및 클라우드 모델(OpenAI/Groq) 지원",
16
+ "keywords": ["Lattice AI", "MLX", "FastAPI", "VS Code", "Telegram", "local LLM"],
17
+ "sections": [
18
+ { "heading": "Installation", "summary": "pip install ltcai 후 ltcai start로 실행" },
19
+ { "heading": "Features", "summary": "채팅, 에이전트 모드, 파일 편집, 웹 검색 등 지원" }
20
+ ],
21
+ "word_count": 1240
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## 2. PDF summary with focus section (success)
27
+
28
+ **Input**
29
+ ```json
30
+ { "path": "~/docs/report.pdf", "style": "paragraph", "focus_sections": ["결론", "권고사항"], "max_length": 300 }
31
+ ```
32
+ **Output**
33
+ ```json
34
+ {
35
+ "success": true,
36
+ "result": {
37
+ "title": "report.pdf",
38
+ "summary": "보고서의 결론: 시스템 성능이 전분기 대비 23% 향상되었으며, 추가 최적화를 위해 캐시 레이어 도입이 권고됩니다.",
39
+ "keywords": ["성능", "최적화", "캐시", "권고"],
40
+ "word_count": 8500
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## 3. Unsupported format (failure)
46
+
47
+ **Input**
48
+ ```json
49
+ { "path": "~/data/sales.csv" }
50
+ ```
51
+ **Output**
52
+ ```json
53
+ { "success": false, "error": "UNSUPPORTED_FORMAT", "message": "Supported formats: txt, md, pdf, docx. Use data_analysis for CSV files." }
54
+ ```
55
+
56
+ ## 4. File not found (failure)
57
+
58
+ **Input**
59
+ ```json
60
+ { "path": "~/docs/nonexistent.md" }
61
+ ```
62
+ **Output**
63
+ ```json
64
+ { "success": false, "error": "FILE_NOT_FOUND", "message": "No such file: /home/user/docs/nonexistent.md" }
65
+ ```
@@ -0,0 +1,9 @@
1
+ {
2
+ "risk": "read",
3
+ "destructive": false,
4
+ "shell": false,
5
+ "network": false,
6
+ "auto_approve": true,
7
+ "sandbox": "home",
8
+ "rollback": "none"
9
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "summarize_document",
4
+ "input": {
5
+ "required": ["path"],
6
+ "optional": ["max_length", "style", "focus_sections"],
7
+ "properties": {
8
+ "path": { "type": "string", "description": "절대 경로 또는 ~/로 시작하는 경로 (.txt, .md, .pdf, .docx)" },
9
+ "max_length": { "type": "integer", "default": 500, "description": "요약 최대 글자 수" },
10
+ "style": { "type": "string", "enum": ["bullet","paragraph","table"], "default": "bullet", "description": "출력 형식" },
11
+ "focus_sections": { "type": "array", "items": { "type": "string" }, "description": "특정 섹션만 요약할 때 섹션 제목 목록" }
12
+ }
13
+ },
14
+ "output": {
15
+ "oneOf": [
16
+ {
17
+ "title": "success",
18
+ "properties": {
19
+ "success": { "const": true },
20
+ "result": {
21
+ "properties": {
22
+ "title": { "type": "string", "description": "문서 제목 또는 파일명" },
23
+ "summary": { "type": "string", "description": "핵심 요약" },
24
+ "keywords": { "type": "array", "items": { "type": "string" }, "description": "주요 키워드 (최대 10개)" },
25
+ "sections": {
26
+ "type": "array",
27
+ "items": {
28
+ "properties": {
29
+ "heading": { "type": "string" },
30
+ "summary": { "type": "string" }
31
+ },
32
+ "required": ["heading","summary"]
33
+ },
34
+ "description": "섹션별 요약 (있는 경우)"
35
+ },
36
+ "word_count": { "type": "integer" }
37
+ },
38
+ "required": ["title","summary","keywords"]
39
+ }
40
+ },
41
+ "required": ["success","result"]
42
+ },
43
+ {
44
+ "title": "failure",
45
+ "properties": {
46
+ "success": { "const": false },
47
+ "error": { "type": "string", "enum": ["INVALID_INPUT","FILE_NOT_FOUND","UNSUPPORTED_FORMAT","SIZE_LIMIT","MODEL_NOT_LOADED"] },
48
+ "message": { "type": "string" }
49
+ },
50
+ "required": ["success","error","message"]
51
+ }
52
+ ]
53
+ },
54
+ "evals": [
55
+ {
56
+ "id": "markdown_summary",
57
+ "input": { "path": "__TEST__/README.md", "style": "bullet" },
58
+ "pass_criteria": "success == true and len(keywords) >= 1"
59
+ },
60
+ {
61
+ "id": "unsupported_format",
62
+ "input": { "path": "__TEST__/data.csv" },
63
+ "pass_criteria": "error == UNSUPPORTED_FORMAT"
64
+ },
65
+ {
66
+ "id": "missing_path",
67
+ "input": {},
68
+ "pass_criteria": "error == INVALID_INPUT"
69
+ }
70
+ ]
71
+ }
@@ -9,6 +9,34 @@
9
9
  ## 설명
10
10
  외부 검색 엔진(DuckDuckGo/Brave)을 통해 웹 검색을 수행하고 상위 결과를 반환한다.
11
11
 
12
+ ## 거버넌스
13
+
14
+ `policies/policy.md` 참고. 요약: `risk=read`, `destructive=false`, `shell=false`, `network=true`, `auto_approve=true`, `sandbox=system`, `rollback=none`
15
+
16
+ ## 트리거 조건
17
+
18
+ 호출해야 하는 상황:
19
+ - 사용자가 "검색해줘", "찾아봐줘", "최신 정보 알려줘"라고 요청할 때
20
+ - 에이전트가 Discover 단계에서 외부 문서/API/라이브러리 정보를 수집해야 할 때
21
+ - LLM의 학습 데이터 이후 최신 정보(라이브러리 버전, 뉴스 등)가 필요할 때
22
+
23
+ 호출하면 **안** 되는 상황:
24
+ - 로컬 파일 내용 검색 시 → `grep` 사용
25
+ - LLM이 이미 알고 있는 일반 지식 질문 시
26
+
27
+ ## Side Effects
28
+
29
+ | 항목 | 내용 |
30
+ |------|------|
31
+ | 파일 변경 | 없음 |
32
+ | 생성 파일 | 없음 |
33
+ | 프로세스 | 없음 |
34
+ | 네트워크 | 외부 검색 API로 검색어 전송 (DuckDuckGo/Brave) |
35
+
36
+ ## Rollback
37
+
38
+ 없음. 읽기 전용 네트워크 요청.
39
+
12
40
  ## 입력 스키마
13
41
  ```json
14
42
  {
@@ -0,0 +1,61 @@
1
+ # web_search — Examples
2
+
3
+ ## 1. Basic search (success)
4
+
5
+ **Input**
6
+ ```json
7
+ { "query": "FastAPI 비동기 처리", "num_results": 3 }
8
+ ```
9
+ **Output**
10
+ ```json
11
+ {
12
+ "success": true,
13
+ "result": {
14
+ "query": "FastAPI 비동기 처리",
15
+ "results": [
16
+ { "title": "FastAPI - Async", "url": "https://fastapi.tiangolo.com/async/", "snippet": "FastAPI는 Python의 asyncio를 완벽히 지원합니다..." }
17
+ ]
18
+ }
19
+ }
20
+ ```
21
+
22
+ ## 2. Search with language (success)
23
+
24
+ **Input**
25
+ ```json
26
+ { "query": "Python type hints best practices", "num_results": 5, "lang": "en-US" }
27
+ ```
28
+ **Output**
29
+ ```json
30
+ {
31
+ "success": true,
32
+ "result": {
33
+ "query": "Python type hints best practices",
34
+ "results": [
35
+ { "title": "PEP 484 – Type Hints", "url": "https://peps.python.org/pep-0484/", "snippet": "This PEP introduces a standard syntax for type annotations..." }
36
+ ]
37
+ }
38
+ }
39
+ ```
40
+
41
+ ## 3. Empty query (failure)
42
+
43
+ **Input**
44
+ ```json
45
+ { "query": "" }
46
+ ```
47
+ **Output**
48
+ ```json
49
+ { "success": false, "error": "INVALID_INPUT", "message": "query is required" }
50
+ ```
51
+
52
+ ## 4. Network error (failure)
53
+
54
+ **Input**
55
+ ```json
56
+ { "query": "offline test" }
57
+ ```
58
+ **Output**
59
+ ```json
60
+ { "success": false, "error": "NETWORK_ERROR", "message": "외부 검색 API에 연결할 수 없습니다. 잠시 후 재시도하세요." }
61
+ ```
@@ -0,0 +1,9 @@
1
+ {
2
+ "risk": "read",
3
+ "destructive": false,
4
+ "shell": false,
5
+ "network": true,
6
+ "auto_approve": true,
7
+ "sandbox": "system",
8
+ "rollback": "none"
9
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "web_search",
4
+ "input": {
5
+ "required": ["query"],
6
+ "optional": ["num_results", "lang"],
7
+ "properties": {
8
+ "query": { "type": "string", "description": "검색어" },
9
+ "num_results": { "type": "integer", "default": 5, "minimum": 1, "maximum": 20 },
10
+ "lang": { "type": "string", "default": "ko-KR" }
11
+ }
12
+ },
13
+ "output": {
14
+ "oneOf": [
15
+ {
16
+ "title": "success",
17
+ "properties": {
18
+ "success": { "const": true },
19
+ "result": {
20
+ "properties": {
21
+ "query": { "type": "string" },
22
+ "results": {
23
+ "type": "array",
24
+ "items": {
25
+ "properties": {
26
+ "title": { "type": "string" },
27
+ "url": { "type": "string", "format": "uri" },
28
+ "snippet": { "type": "string" }
29
+ },
30
+ "required": ["title","url","snippet"]
31
+ }
32
+ }
33
+ },
34
+ "required": ["query","results"]
35
+ }
36
+ },
37
+ "required": ["success","result"]
38
+ },
39
+ {
40
+ "title": "failure",
41
+ "properties": {
42
+ "success": { "const": false },
43
+ "error": { "type": "string", "enum": ["INVALID_INPUT","NETWORK_ERROR","TIMEOUT"] },
44
+ "message": { "type": "string" }
45
+ },
46
+ "required": ["success","error","message"]
47
+ }
48
+ ]
49
+ },
50
+ "evals": [
51
+ {
52
+ "id": "basic_search",
53
+ "input": { "query": "FastAPI async", "num_results": 3 },
54
+ "pass_criteria": "success == true and len(results) >= 1"
55
+ },
56
+ {
57
+ "id": "empty_query",
58
+ "input": { "query": "" },
59
+ "pass_criteria": "error == INVALID_INPUT"
60
+ }
61
+ ]
62
+ }
@@ -5,7 +5,28 @@ from pathlib import Path
5
5
 
6
6
  sys.path.insert(0, str(Path(__file__).parent.parent.parent))
7
7
 
8
- from tools import local_list, local_read, local_write, read_document, ToolError
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
9
30
 
10
31
 
11
32
  # ---------------------------------------------------------------------------
@@ -125,3 +146,175 @@ def test_read_document_csv(tmp_path):
125
146
  result = read_document(str(f))
126
147
  # should not raise; returns some text content
127
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")