chp-adapter-filesystem 0.11.0__tar.gz

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.
@@ -0,0 +1,36 @@
1
+ node_modules/
2
+ dist/
3
+ .next/
4
+ out/
5
+ .turbo/
6
+ *.tsbuildinfo
7
+ .yalc/
8
+ yalc.lock
9
+ *.tgz
10
+ .env
11
+ .env.*
12
+ coverage/
13
+ dashboard/
14
+ components/
15
+ # ...but the cockpit's app source lives in app/components — never ignore real source.
16
+ !cockpit/app/components/
17
+ !cockpit/app/components/**
18
+ !chp-evidence/app/components/
19
+ !chp-evidence/app/components/**
20
+ .tech-hub-cache/
21
+ __pycache__/
22
+ *.py[cod]
23
+ .pytest_cache/
24
+ .chp/
25
+ .DS_Store
26
+ .coverage
27
+ .forge/
28
+ .hypothesis/
29
+ .claude/
30
+
31
+ # chp-agent evidence store
32
+ .chp-agent/sessions.sqlite
33
+ .publish-dist/
34
+
35
+ # matrix-bot runtime session registry (CC session ids)
36
+ matrix-bot/sessions.json
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: chp-adapter-filesystem
3
+ Version: 0.11.0
4
+ Summary: CHP capability adapter — governed file read/write/list with path allowlist
5
+ Author: Auxo
6
+ License: Apache-2.0
7
+ Keywords: adapter,capability-host-protocol,chp,files,filesystem
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: chp-core>=0.7.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
File without changes
@@ -0,0 +1,25 @@
1
+ """chp-adapter-filesystem — governed file read/write/list with path allowlist.
2
+
3
+ Four capabilities:
4
+
5
+ * ``read_file`` — read a file's content; content absent from evidence
6
+ * ``write_file`` — write or overwrite a file; content absent from evidence
7
+ * ``list_directory`` — list entries with optional glob pattern
8
+ * ``stat_path`` — check existence, type, and size
9
+
10
+ Usage::
11
+
12
+ from chp_core import LocalCapabilityHost, register_adapter
13
+ from chp_adapter_filesystem import FilesystemAdapter, FilesystemConfig
14
+ import tempfile
15
+
16
+ host = LocalCapabilityHost()
17
+ with tempfile.TemporaryDirectory() as tmp:
18
+ register_adapter(host, FilesystemAdapter(FilesystemConfig(allowed_roots=[tmp])))
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .adapter import FilesystemAdapter, FilesystemConfig
24
+
25
+ __all__ = ["FilesystemAdapter", "FilesystemConfig"]
@@ -0,0 +1,508 @@
1
+ """FilesystemAdapter — governed file read/write/list as CHP capabilities.
2
+
3
+ Safety invariants (MUST PRESERVE):
4
+ * Every path is resolved via ``Path(path).resolve()`` before use.
5
+ * If ``allowed_roots`` is set, resolved path must start with one of the roots.
6
+ * File content is NEVER stored in evidence — only path, size, and metadata.
7
+ * ``max_read_bytes`` and ``max_write_bytes`` cap I/O to prevent runaway reads.
8
+ * grep match text and glob file lists are NOT stored in evidence — only counts.
9
+
10
+ Six capabilities:
11
+
12
+ * ``read_file`` — read a file's content (UTF-8 or specified encoding)
13
+ * ``write_file`` — write or overwrite a file; optionally create parent dirs
14
+ * ``list_directory`` — list entries in a directory with optional glob pattern
15
+ * ``stat_path`` — check existence, type, and size of a path
16
+ * ``grep`` — search files by regex pattern (uses grep/rg)
17
+ * ``glob_files`` — recursive glob file discovery
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fnmatch
23
+ import glob as _glob
24
+ import shutil
25
+ import subprocess
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from chp_core import BaseAdapter, capability
32
+
33
+ _EMITS = [
34
+ "fs_read", "fs_write", "fs_list", "fs_stat",
35
+ "fs_grep", "fs_glob",
36
+ "fs_error", "fs_access_denied",
37
+ ]
38
+
39
+ _MAX_GREP_RESULTS = 200
40
+ _MAX_GLOB_RESULTS = 500
41
+
42
+
43
+ def _is_within(path: Path, root: Path) -> bool:
44
+ """True if *path* is *root* or lives under it — component-wise, not string
45
+ prefix (so '/srv/data-secret' is not 'within' '/srv/data')."""
46
+ try:
47
+ return path == root or path.is_relative_to(root)
48
+ except AttributeError: # Python < 3.9
49
+ import os
50
+ try:
51
+ return os.path.commonpath([str(path), str(root)]) == str(root)
52
+ except ValueError:
53
+ return False
54
+
55
+
56
+ @dataclass
57
+ class FilesystemConfig:
58
+ """Config for FilesystemAdapter.
59
+
60
+ ``allowed_roots`` — if non-None, all paths must resolve under one of
61
+ these roots (prevents escaping a sandbox). Values are resolved at check
62
+ time, so relative roots are resolved relative to CWD at call time.
63
+
64
+ ``max_read_bytes`` / ``max_write_bytes`` — size guards; operations that
65
+ would exceed these limits return failure.
66
+ """
67
+
68
+ allowed_roots: list[str] | None = None
69
+ max_read_bytes: int = 1 * 1024 * 1024 # 1 MB
70
+ max_write_bytes: int = 512 * 1024 # 512 KB
71
+
72
+
73
+ class FilesystemAdapter(BaseAdapter):
74
+ """Governed file read/write/list with path allowlist."""
75
+
76
+ adapter_id = "chp.adapters.filesystem"
77
+ adapter_name = "Filesystem"
78
+ adapter_description = "Governed file system access with configurable path restrictions."
79
+ adapter_category = "execution"
80
+ adapter_tags = ["filesystem", "files", "io"]
81
+
82
+ def __init__(self, config: FilesystemConfig | None = None) -> None:
83
+ self._config = config or FilesystemConfig()
84
+
85
+ # ------------------------------------------------------------------
86
+ # Internal path safety
87
+ # ------------------------------------------------------------------
88
+
89
+ def _check_path(self, path: str) -> Path:
90
+ """Resolve *path* and verify it is under an allowed root (if configured)."""
91
+ resolved = Path(path).resolve()
92
+ if self._config.allowed_roots is not None:
93
+ # Component-wise containment, NOT string prefix: root '/srv/data'
94
+ # must not admit sibling '/srv/data-secret'. is_relative_to compares
95
+ # path parts, so '/srv/data-secret' is correctly rejected.
96
+ allowed = [Path(r).resolve() for r in self._config.allowed_roots]
97
+ if not any(_is_within(resolved, a) for a in allowed):
98
+ raise PermissionError(
99
+ f"Path {resolved} is outside allowed roots"
100
+ )
101
+ return resolved
102
+
103
+ # ------------------------------------------------------------------
104
+ # read_file
105
+ # ------------------------------------------------------------------
106
+
107
+ @capability(
108
+ id="chp.adapters.filesystem.read_file",
109
+ version="1.0.0",
110
+ description="Read a file's content.",
111
+ category="execution",
112
+ risk="low",
113
+ input_schema={
114
+ "type": "object",
115
+ "properties": {
116
+ "path": {"type": "string", "description": "File path to read."},
117
+ "encoding": {
118
+ "type": "string",
119
+ "description": "Text encoding (default utf-8).",
120
+ },
121
+ },
122
+ "required": ["path"],
123
+ "additionalProperties": False,
124
+ },
125
+ emits=_EMITS,
126
+ tags=["filesystem", "read"],
127
+ )
128
+ async def read_file(self, ctx: Any, payload: dict) -> dict:
129
+ encoding = payload.get("encoding", "utf-8")
130
+ try:
131
+ resolved = self._check_path(payload["path"])
132
+ except PermissionError as exc:
133
+ ctx.emit("fs_access_denied", {"path": payload["path"], "reason": str(exc)},
134
+ redacted=False)
135
+ raise
136
+
137
+ if not resolved.exists():
138
+ ctx.emit("fs_error", {
139
+ "op": "read_file", "path": str(resolved), "reason": "not_found",
140
+ }, redacted=False)
141
+ raise FileNotFoundError(f"File not found: {resolved}")
142
+
143
+ if not resolved.is_file():
144
+ ctx.emit("fs_error", {
145
+ "op": "read_file", "path": str(resolved), "reason": "not_a_file",
146
+ }, redacted=False)
147
+ raise IsADirectoryError(f"Path is a directory: {resolved}")
148
+
149
+ size = resolved.stat().st_size
150
+ if size > self._config.max_read_bytes:
151
+ ctx.emit("fs_error", {
152
+ "op": "read_file", "path": str(resolved),
153
+ "reason": "file_too_large",
154
+ "size_bytes": size,
155
+ "max_read_bytes": self._config.max_read_bytes,
156
+ }, redacted=False)
157
+ raise ValueError(
158
+ f"File too large to read: {size} bytes > max {self._config.max_read_bytes}"
159
+ )
160
+
161
+ content = resolved.read_text(encoding=encoding)
162
+
163
+ ctx.emit("fs_read", {
164
+ "path": str(resolved),
165
+ "size_bytes": size,
166
+ "encoding": encoding,
167
+ }, redacted=False)
168
+
169
+ return {"path": str(resolved), "content": content, "size_bytes": size, "encoding": encoding}
170
+
171
+ # ------------------------------------------------------------------
172
+ # write_file
173
+ # ------------------------------------------------------------------
174
+
175
+ @capability(
176
+ id="chp.adapters.filesystem.write_file",
177
+ version="1.0.0",
178
+ description="Write or overwrite a file.",
179
+ category="execution",
180
+ risk="high",
181
+ input_schema={
182
+ "type": "object",
183
+ "properties": {
184
+ "path": {"type": "string", "description": "File path to write."},
185
+ "content": {"type": "string", "description": "Text content to write."},
186
+ "encoding": {"type": "string", "description": "Text encoding (default utf-8)."},
187
+ "create_parents": {
188
+ "type": "boolean",
189
+ "description": "Create parent directories if they don't exist.",
190
+ },
191
+ },
192
+ "required": ["path", "content"],
193
+ "additionalProperties": False,
194
+ },
195
+ emits=_EMITS,
196
+ tags=["filesystem", "write"],
197
+ )
198
+ async def write_file(self, ctx: Any, payload: dict) -> dict:
199
+ encoding = payload.get("encoding", "utf-8")
200
+ create_parents = payload.get("create_parents", False)
201
+ content = payload["content"]
202
+
203
+ encoded = content.encode(encoding)
204
+ if len(encoded) > self._config.max_write_bytes:
205
+ ctx.emit("fs_error", {
206
+ "op": "write_file", "path": payload["path"],
207
+ "reason": "content_too_large",
208
+ "size_bytes": len(encoded),
209
+ "max_write_bytes": self._config.max_write_bytes,
210
+ }, redacted=False)
211
+ raise ValueError(
212
+ f"Content too large to write: {len(encoded)} bytes > max {self._config.max_write_bytes}"
213
+ )
214
+
215
+ try:
216
+ resolved = self._check_path(payload["path"])
217
+ except PermissionError as exc:
218
+ ctx.emit("fs_access_denied", {"path": payload["path"], "reason": str(exc)},
219
+ redacted=False)
220
+ raise
221
+
222
+ created = not resolved.exists()
223
+ if create_parents:
224
+ resolved.parent.mkdir(parents=True, exist_ok=True)
225
+
226
+ resolved.write_text(content, encoding=encoding)
227
+
228
+ ctx.emit("fs_write", {
229
+ "path": str(resolved),
230
+ "written_bytes": len(encoded),
231
+ "created": created,
232
+ }, redacted=False)
233
+
234
+ return {"path": str(resolved), "written_bytes": len(encoded), "created": created}
235
+
236
+ # ------------------------------------------------------------------
237
+ # list_directory
238
+ # ------------------------------------------------------------------
239
+
240
+ @capability(
241
+ id="chp.adapters.filesystem.list_directory",
242
+ version="1.0.0",
243
+ description="List entries in a directory.",
244
+ category="execution",
245
+ risk="low",
246
+ input_schema={
247
+ "type": "object",
248
+ "properties": {
249
+ "path": {"type": "string", "description": "Directory path to list."},
250
+ "pattern": {
251
+ "type": "string",
252
+ "description": "Glob pattern to filter entries (e.g. '*.py').",
253
+ },
254
+ },
255
+ "required": ["path"],
256
+ "additionalProperties": False,
257
+ },
258
+ emits=_EMITS,
259
+ tags=["filesystem", "list"],
260
+ )
261
+ async def list_directory(self, ctx: Any, payload: dict) -> dict:
262
+ pattern = payload.get("pattern")
263
+
264
+ try:
265
+ resolved = self._check_path(payload["path"])
266
+ except PermissionError as exc:
267
+ ctx.emit("fs_access_denied", {"path": payload["path"], "reason": str(exc)},
268
+ redacted=False)
269
+ raise
270
+
271
+ if not resolved.exists():
272
+ ctx.emit("fs_error", {
273
+ "op": "list_directory", "path": str(resolved), "reason": "not_found",
274
+ }, redacted=False)
275
+ raise FileNotFoundError(f"Directory not found: {resolved}")
276
+
277
+ if not resolved.is_dir():
278
+ ctx.emit("fs_error", {
279
+ "op": "list_directory", "path": str(resolved), "reason": "not_a_directory",
280
+ }, redacted=False)
281
+ raise NotADirectoryError(f"Path is not a directory: {resolved}")
282
+
283
+ entries = []
284
+ for child in sorted(resolved.iterdir()):
285
+ if pattern and not fnmatch.fnmatch(child.name, pattern):
286
+ continue
287
+ entry_type = "directory" if child.is_dir() else "file"
288
+ try:
289
+ size = child.stat().st_size if child.is_file() else None
290
+ except OSError:
291
+ size = None
292
+ entries.append({"name": child.name, "type": entry_type, "size_bytes": size})
293
+
294
+ ctx.emit("fs_list", {
295
+ "path": str(resolved),
296
+ "pattern": pattern,
297
+ "count": len(entries),
298
+ }, redacted=False)
299
+
300
+ return {"path": str(resolved), "entries": entries, "count": len(entries)}
301
+
302
+ # ------------------------------------------------------------------
303
+ # stat_path
304
+ # ------------------------------------------------------------------
305
+
306
+ @capability(
307
+ id="chp.adapters.filesystem.stat_path",
308
+ version="1.0.0",
309
+ description="Check existence, type, and size of a path.",
310
+ category="execution",
311
+ risk="low",
312
+ input_schema={
313
+ "type": "object",
314
+ "properties": {
315
+ "path": {"type": "string", "description": "Path to stat."},
316
+ },
317
+ "required": ["path"],
318
+ "additionalProperties": False,
319
+ },
320
+ emits=_EMITS,
321
+ tags=["filesystem"],
322
+ )
323
+ async def stat_path(self, ctx: Any, payload: dict) -> dict:
324
+ try:
325
+ resolved = self._check_path(payload["path"])
326
+ except PermissionError as exc:
327
+ ctx.emit("fs_access_denied", {"path": payload["path"], "reason": str(exc)},
328
+ redacted=False)
329
+ raise
330
+
331
+ if not resolved.exists():
332
+ ctx.emit("fs_stat", {"path": str(resolved), "exists": False}, redacted=False)
333
+ return {"path": str(resolved), "exists": False, "type": None,
334
+ "size_bytes": None, "modified_at": None}
335
+
336
+ stat = resolved.stat()
337
+ entry_type = "directory" if resolved.is_dir() else "file"
338
+ import datetime
339
+ modified_at = datetime.datetime.fromtimestamp(
340
+ stat.st_mtime, tz=datetime.timezone.utc
341
+ ).isoformat()
342
+
343
+ ctx.emit("fs_stat", {
344
+ "path": str(resolved),
345
+ "exists": True,
346
+ "type": entry_type,
347
+ "size_bytes": stat.st_size,
348
+ }, redacted=False)
349
+
350
+ return {
351
+ "path": str(resolved),
352
+ "exists": True,
353
+ "type": entry_type,
354
+ "size_bytes": stat.st_size,
355
+ "modified_at": modified_at,
356
+ }
357
+
358
+ # ------------------------------------------------------------------
359
+ # grep
360
+ # ------------------------------------------------------------------
361
+
362
+ @capability(
363
+ id="chp.adapters.filesystem.grep",
364
+ version="1.0.0",
365
+ description=(
366
+ "Search files for a regex pattern. Uses ripgrep (rg) when available, "
367
+ "falls back to grep. Returns up to 200 matches with file, line number, "
368
+ "and matched text. Match content is NOT stored in evidence."
369
+ ),
370
+ category="execution",
371
+ risk="low",
372
+ input_schema={
373
+ "type": "object",
374
+ "properties": {
375
+ "pattern": {"type": "string", "description": "Regex pattern to search for."},
376
+ "path": {"type": "string", "description": "Directory or file to search."},
377
+ "glob": {"type": "string", "description": "File glob filter, e.g. '*.py'."},
378
+ "case_insensitive": {"type": "boolean", "description": "Case-insensitive search."},
379
+ },
380
+ "required": ["pattern", "path"],
381
+ "additionalProperties": False,
382
+ },
383
+ emits=_EMITS,
384
+ tags=["filesystem", "search"],
385
+ )
386
+ async def grep(self, ctx: Any, payload: dict) -> dict:
387
+ pattern = payload["pattern"]
388
+ glob_filter = payload.get("glob")
389
+ case_insensitive = payload.get("case_insensitive", False)
390
+
391
+ try:
392
+ resolved = self._check_path(payload["path"])
393
+ except PermissionError as exc:
394
+ ctx.emit("fs_access_denied", {"path": payload["path"], "reason": str(exc)}, redacted=False)
395
+ raise
396
+
397
+ t0 = time.monotonic()
398
+ use_rg = shutil.which("rg") is not None
399
+
400
+ if use_rg:
401
+ cmd = ["rg", "--line-number", "--no-heading", "--color=never"]
402
+ if case_insensitive:
403
+ cmd.append("-i")
404
+ if glob_filter:
405
+ cmd.extend(["-g", glob_filter])
406
+ cmd.extend([pattern, str(resolved)])
407
+ else:
408
+ cmd = ["grep", "-rn"]
409
+ if case_insensitive:
410
+ cmd.append("-i")
411
+ if glob_filter:
412
+ cmd.extend([f"--include={glob_filter}"])
413
+ cmd.extend([pattern, str(resolved)])
414
+
415
+ try:
416
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
417
+ except subprocess.TimeoutExpired:
418
+ ctx.emit("fs_error", {"op": "grep", "path": str(resolved), "reason": "timeout"}, redacted=False)
419
+ raise TimeoutError(f"grep timed out searching {resolved}")
420
+
421
+ matches = []
422
+ truncated = False
423
+ for line in proc.stdout.splitlines():
424
+ if len(matches) >= _MAX_GREP_RESULTS:
425
+ truncated = True
426
+ break
427
+ # rg and grep -n both output: file:line:text
428
+ parts = line.split(":", 2)
429
+ if len(parts) >= 3:
430
+ matches.append({"file": parts[0], "line_no": parts[1], "text": parts[2]})
431
+ elif len(parts) == 2:
432
+ matches.append({"file": parts[0], "line_no": parts[1], "text": ""})
433
+
434
+ latency_ms = round((time.monotonic() - t0) * 1000)
435
+ ctx.emit("fs_grep", {
436
+ "path": str(resolved),
437
+ "pattern": pattern,
438
+ "glob": glob_filter,
439
+ "match_count": len(matches),
440
+ "truncated": truncated,
441
+ "latency_ms": latency_ms,
442
+ }, redacted=False)
443
+
444
+ return {"matches": matches, "match_count": len(matches), "truncated": truncated}
445
+
446
+ # ------------------------------------------------------------------
447
+ # glob_files
448
+ # ------------------------------------------------------------------
449
+
450
+ @capability(
451
+ id="chp.adapters.filesystem.glob_files",
452
+ version="1.0.0",
453
+ description=(
454
+ "Recursively discover files matching a glob pattern. "
455
+ "Supports ** for recursive matching. Returns up to 500 file paths."
456
+ ),
457
+ category="execution",
458
+ risk="low",
459
+ input_schema={
460
+ "type": "object",
461
+ "properties": {
462
+ "pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.py'."},
463
+ "base_path": {"type": "string", "description": "Base directory to search from (default: CWD)."},
464
+ },
465
+ "required": ["pattern"],
466
+ "additionalProperties": False,
467
+ },
468
+ emits=_EMITS,
469
+ tags=["filesystem", "search"],
470
+ )
471
+ async def glob_files(self, ctx: Any, payload: dict) -> dict:
472
+ pattern = payload["pattern"]
473
+ base_path = payload.get("base_path", ".")
474
+
475
+ try:
476
+ resolved_base = self._check_path(base_path)
477
+ except PermissionError as exc:
478
+ ctx.emit("fs_access_denied", {"path": base_path, "reason": str(exc)}, redacted=False)
479
+ raise
480
+
481
+ t0 = time.monotonic()
482
+ raw = _glob.glob(pattern, root_dir=str(resolved_base), recursive=True)
483
+ # A pattern like '../../etc/*' escapes root_dir — re-validate every
484
+ # returned path against the allowed roots, dropping any that resolve out.
485
+ if self._config.allowed_roots is not None:
486
+ kept = []
487
+ for rel in raw:
488
+ try:
489
+ self._check_path(str(resolved_base / rel))
490
+ except PermissionError:
491
+ continue
492
+ kept.append(rel)
493
+ raw = kept
494
+ raw.sort()
495
+
496
+ truncated = len(raw) > _MAX_GLOB_RESULTS
497
+ files = raw[:_MAX_GLOB_RESULTS]
498
+
499
+ latency_ms = round((time.monotonic() - t0) * 1000)
500
+ ctx.emit("fs_glob", {
501
+ "pattern": pattern,
502
+ "base_path": str(resolved_base),
503
+ "count": len(files),
504
+ "truncated": truncated,
505
+ "latency_ms": latency_ms,
506
+ }, redacted=False)
507
+
508
+ return {"files": files, "count": len(files), "truncated": truncated}
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chp-adapter-filesystem"
7
+ version = "0.11.0"
8
+ description = "CHP capability adapter — governed file read/write/list with path allowlist"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Auxo" }]
13
+ keywords = ["chp", "capability-host-protocol", "filesystem", "files", "adapter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "chp-core>=0.7.0",
27
+ ]
28
+
29
+ [project.entry-points."chp.adapters"]
30
+ filesystem = "chp_adapter_filesystem:FilesystemAdapter"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8.0"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ pythonpath = ["."]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["chp_adapter_filesystem"]
@@ -0,0 +1,486 @@
1
+ """Tests for chp_adapter_filesystem.adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from chp_core import LocalCapabilityHost, register_adapter
12
+ from chp_core.store import SQLiteEvidenceStore
13
+
14
+ from chp_adapter_filesystem import FilesystemAdapter, FilesystemConfig
15
+
16
+
17
+ # --------------------------------------------------------------------------
18
+ # Helpers
19
+ # --------------------------------------------------------------------------
20
+
21
+ def _make_host(config=None):
22
+ host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
23
+ register_adapter(host, FilesystemAdapter(config))
24
+ return host
25
+
26
+
27
+ def _cap_events(store):
28
+ return [e for e in store.all() if "capability_uri" not in e["payload"]]
29
+
30
+
31
+ # --------------------------------------------------------------------------
32
+ # Path confinement (sibling-prefix + glob escape)
33
+ # --------------------------------------------------------------------------
34
+
35
+ class TestConfinement:
36
+ def test_sibling_prefix_root_is_rejected(self, tmp_path):
37
+ # allowed_roots=[.../data] must NOT admit sibling .../data-secret
38
+ root = tmp_path / "data"
39
+ root.mkdir()
40
+ sibling = tmp_path / "data-secret"
41
+ sibling.mkdir()
42
+ secret = sibling / "s.txt"
43
+ secret.write_text("top secret")
44
+ host = _make_host(FilesystemConfig(allowed_roots=[str(root)]))
45
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(secret)})
46
+ assert r.outcome == "failure"
47
+
48
+ def test_path_inside_root_still_allowed(self, tmp_path):
49
+ root = tmp_path / "data"
50
+ root.mkdir()
51
+ f = root / "ok.txt"
52
+ f.write_text("fine")
53
+ host = _make_host(FilesystemConfig(allowed_roots=[str(root)]))
54
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(f)})
55
+ assert r.outcome == "success"
56
+
57
+ def test_glob_escape_paths_dropped(self, tmp_path):
58
+ root = tmp_path / "data"
59
+ root.mkdir()
60
+ (root / "inside.txt").write_text("x")
61
+ (tmp_path / "outside.txt").write_text("secret")
62
+ host = _make_host(FilesystemConfig(allowed_roots=[str(root)]))
63
+ r = host.invoke("chp.adapters.filesystem.glob_files", {"pattern": "../*", "base_path": str(root)})
64
+ assert r.outcome == "success"
65
+ # nothing outside the root leaks through the '../' escape
66
+ assert not any("outside.txt" in f for f in r.data["files"])
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # 1. Shaping
71
+ # --------------------------------------------------------------------------
72
+
73
+ class TestShaping:
74
+ def test_six_capabilities(self):
75
+ ids = {c.descriptor.id for c in FilesystemAdapter().capabilities()}
76
+ assert ids == {
77
+ "chp.adapters.filesystem.read_file",
78
+ "chp.adapters.filesystem.write_file",
79
+ "chp.adapters.filesystem.list_directory",
80
+ "chp.adapters.filesystem.stat_path",
81
+ "chp.adapters.filesystem.grep",
82
+ "chp.adapters.filesystem.glob_files",
83
+ }
84
+
85
+ def test_write_is_high_risk(self):
86
+ caps = {c.descriptor.id: c.descriptor for c in FilesystemAdapter().capabilities()}
87
+ assert caps["chp.adapters.filesystem.write_file"].risk == "high"
88
+
89
+ def test_others_are_low_risk(self):
90
+ caps = {c.descriptor.id: c.descriptor for c in FilesystemAdapter().capabilities()}
91
+ for cap_id in [
92
+ "chp.adapters.filesystem.read_file",
93
+ "chp.adapters.filesystem.list_directory",
94
+ "chp.adapters.filesystem.stat_path",
95
+ ]:
96
+ assert caps[cap_id].risk == "low"
97
+
98
+
99
+ # --------------------------------------------------------------------------
100
+ # 2. read_file
101
+ # --------------------------------------------------------------------------
102
+
103
+ class TestReadFile:
104
+ def test_read_existing_file(self, tmp_path):
105
+ f = tmp_path / "hello.txt"
106
+ f.write_text("hello world")
107
+ host = _make_host()
108
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(f)})
109
+ assert r.outcome == "success"
110
+ assert r.data["content"] == "hello world"
111
+ assert r.data["size_bytes"] == 11
112
+
113
+ def test_missing_file_fails(self, tmp_path):
114
+ host = _make_host()
115
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(tmp_path / "nope.txt")})
116
+ assert r.outcome == "failure"
117
+
118
+ def test_directory_path_fails(self, tmp_path):
119
+ host = _make_host()
120
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(tmp_path)})
121
+ assert r.outcome == "failure"
122
+
123
+ def test_file_too_large_fails(self, tmp_path):
124
+ f = tmp_path / "big.txt"
125
+ f.write_bytes(b"x" * 10)
126
+ host = _make_host(FilesystemConfig(max_read_bytes=5))
127
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(f)})
128
+ assert r.outcome == "failure"
129
+
130
+ def test_missing_path_denied(self):
131
+ host = _make_host()
132
+ r = host.invoke("chp.adapters.filesystem.read_file", {})
133
+ assert r.outcome == "denied"
134
+
135
+ def test_extra_field_denied(self, tmp_path):
136
+ f = tmp_path / "f.txt"
137
+ f.write_text("x")
138
+ host = _make_host()
139
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(f), "injected": "bad"})
140
+ assert r.outcome == "denied"
141
+
142
+
143
+ # --------------------------------------------------------------------------
144
+ # 3. write_file
145
+ # --------------------------------------------------------------------------
146
+
147
+ class TestWriteFile:
148
+ def test_write_new_file(self, tmp_path):
149
+ path = str(tmp_path / "out.txt")
150
+ host = _make_host()
151
+ r = host.invoke("chp.adapters.filesystem.write_file", {"path": path, "content": "new"})
152
+ assert r.outcome == "success"
153
+ assert r.data["created"] is True
154
+ assert Path(path).read_text() == "new"
155
+
156
+ def test_overwrite_existing_file(self, tmp_path):
157
+ f = tmp_path / "existing.txt"
158
+ f.write_text("old")
159
+ host = _make_host()
160
+ r = host.invoke("chp.adapters.filesystem.write_file", {"path": str(f), "content": "new"})
161
+ assert r.outcome == "success"
162
+ assert r.data["created"] is False
163
+ assert f.read_text() == "new"
164
+
165
+ def test_create_parents(self, tmp_path):
166
+ path = str(tmp_path / "deep" / "dir" / "file.txt")
167
+ host = _make_host()
168
+ r = host.invoke("chp.adapters.filesystem.write_file", {
169
+ "path": path, "content": "hi", "create_parents": True
170
+ })
171
+ assert r.outcome == "success"
172
+ assert Path(path).read_text() == "hi"
173
+
174
+ def test_content_too_large_fails(self, tmp_path):
175
+ path = str(tmp_path / "big.txt")
176
+ host = _make_host(FilesystemConfig(max_write_bytes=5))
177
+ r = host.invoke("chp.adapters.filesystem.write_file", {
178
+ "path": path, "content": "0123456789"
179
+ })
180
+ assert r.outcome == "failure"
181
+
182
+
183
+ # --------------------------------------------------------------------------
184
+ # 4. list_directory
185
+ # --------------------------------------------------------------------------
186
+
187
+ class TestListDirectory:
188
+ def test_list_empty_dir(self, tmp_path):
189
+ host = _make_host()
190
+ r = host.invoke("chp.adapters.filesystem.list_directory", {"path": str(tmp_path)})
191
+ assert r.outcome == "success"
192
+ assert r.data["entries"] == []
193
+ assert r.data["count"] == 0
194
+
195
+ def test_list_files_and_dirs(self, tmp_path):
196
+ (tmp_path / "file.txt").write_text("x")
197
+ (tmp_path / "subdir").mkdir()
198
+ host = _make_host()
199
+ r = host.invoke("chp.adapters.filesystem.list_directory", {"path": str(tmp_path)})
200
+ names = {e["name"] for e in r.data["entries"]}
201
+ types = {e["name"]: e["type"] for e in r.data["entries"]}
202
+ assert "file.txt" in names
203
+ assert "subdir" in names
204
+ assert types["file.txt"] == "file"
205
+ assert types["subdir"] == "directory"
206
+
207
+ def test_glob_pattern_filter(self, tmp_path):
208
+ (tmp_path / "a.py").write_text("x")
209
+ (tmp_path / "b.py").write_text("x")
210
+ (tmp_path / "c.txt").write_text("x")
211
+ host = _make_host()
212
+ r = host.invoke("chp.adapters.filesystem.list_directory", {
213
+ "path": str(tmp_path), "pattern": "*.py"
214
+ })
215
+ assert r.data["count"] == 2
216
+ names = {e["name"] for e in r.data["entries"]}
217
+ assert names == {"a.py", "b.py"}
218
+
219
+ def test_nonexistent_dir_fails(self, tmp_path):
220
+ host = _make_host()
221
+ r = host.invoke("chp.adapters.filesystem.list_directory", {
222
+ "path": str(tmp_path / "nope")
223
+ })
224
+ assert r.outcome == "failure"
225
+
226
+ def test_file_path_fails(self, tmp_path):
227
+ f = tmp_path / "f.txt"
228
+ f.write_text("x")
229
+ host = _make_host()
230
+ r = host.invoke("chp.adapters.filesystem.list_directory", {"path": str(f)})
231
+ assert r.outcome == "failure"
232
+
233
+
234
+ # --------------------------------------------------------------------------
235
+ # 5. stat_path
236
+ # --------------------------------------------------------------------------
237
+
238
+ class TestStatPath:
239
+ def test_stat_existing_file(self, tmp_path):
240
+ f = tmp_path / "f.txt"
241
+ f.write_text("hello")
242
+ host = _make_host()
243
+ r = host.invoke("chp.adapters.filesystem.stat_path", {"path": str(f)})
244
+ assert r.outcome == "success"
245
+ assert r.data["exists"] is True
246
+ assert r.data["type"] == "file"
247
+ assert r.data["size_bytes"] == 5
248
+ assert r.data["modified_at"] is not None
249
+
250
+ def test_stat_directory(self, tmp_path):
251
+ host = _make_host()
252
+ r = host.invoke("chp.adapters.filesystem.stat_path", {"path": str(tmp_path)})
253
+ assert r.data["exists"] is True
254
+ assert r.data["type"] == "directory"
255
+
256
+ def test_stat_missing_path(self, tmp_path):
257
+ host = _make_host()
258
+ r = host.invoke("chp.adapters.filesystem.stat_path", {"path": str(tmp_path / "ghost")})
259
+ assert r.outcome == "success"
260
+ assert r.data["exists"] is False
261
+ assert r.data["type"] is None
262
+
263
+
264
+ # --------------------------------------------------------------------------
265
+ # 6. Path allowlist (allowed_roots)
266
+ # --------------------------------------------------------------------------
267
+
268
+ class TestAllowedRoots:
269
+ def test_path_outside_root_denied(self, tmp_path):
270
+ allowed = tmp_path / "allowed"
271
+ allowed.mkdir()
272
+ host = _make_host(FilesystemConfig(allowed_roots=[str(allowed)]))
273
+ # Try to read a file outside the allowed root
274
+ other = tmp_path / "other.txt"
275
+ other.write_text("secret")
276
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(other)})
277
+ assert r.outcome == "failure"
278
+
279
+ def test_path_inside_root_allowed(self, tmp_path):
280
+ allowed = tmp_path / "sandbox"
281
+ allowed.mkdir()
282
+ f = allowed / "ok.txt"
283
+ f.write_text("fine")
284
+ host = _make_host(FilesystemConfig(allowed_roots=[str(allowed)]))
285
+ r = host.invoke("chp.adapters.filesystem.read_file", {"path": str(f)})
286
+ assert r.outcome == "success"
287
+ assert r.data["content"] == "fine"
288
+
289
+ def test_write_outside_root_denied(self, tmp_path):
290
+ allowed = tmp_path / "sandbox"
291
+ allowed.mkdir()
292
+ host = _make_host(FilesystemConfig(allowed_roots=[str(allowed)]))
293
+ r = host.invoke("chp.adapters.filesystem.write_file", {
294
+ "path": str(tmp_path / "escape.txt"), "content": "bad"
295
+ })
296
+ assert r.outcome == "failure"
297
+
298
+ def test_list_outside_root_denied(self, tmp_path):
299
+ allowed = tmp_path / "sandbox"
300
+ allowed.mkdir()
301
+ host = _make_host(FilesystemConfig(allowed_roots=[str(allowed)]))
302
+ r = host.invoke("chp.adapters.filesystem.list_directory", {"path": str(tmp_path)})
303
+ assert r.outcome == "failure"
304
+
305
+
306
+ # --------------------------------------------------------------------------
307
+ # 7. Evidence hygiene
308
+ # --------------------------------------------------------------------------
309
+
310
+ class TestEvidenceHygiene:
311
+ def test_content_not_in_read_evidence(self, tmp_path):
312
+ f = tmp_path / "secret.txt"
313
+ f.write_text("SECRET_CONTENT_XYZ")
314
+ host = _make_host()
315
+ host.invoke("chp.adapters.filesystem.read_file", {"path": str(f)})
316
+ dump = str([e["payload"] for e in _cap_events(host.store)])
317
+ assert "SECRET_CONTENT_XYZ" not in dump
318
+
319
+ def test_content_not_in_write_evidence(self, tmp_path):
320
+ host = _make_host()
321
+ host.invoke("chp.adapters.filesystem.write_file", {
322
+ "path": str(tmp_path / "out.txt"), "content": "WRITTEN_SECRET_ABC"
323
+ })
324
+ dump = str([e["payload"] for e in _cap_events(host.store)])
325
+ assert "WRITTEN_SECRET_ABC" not in dump
326
+
327
+ def test_fs_read_event_emitted(self, tmp_path):
328
+ f = tmp_path / "f.txt"
329
+ f.write_text("x")
330
+ host = _make_host()
331
+ host.invoke("chp.adapters.filesystem.read_file", {"path": str(f)})
332
+ types = [e["event_type"] for e in _cap_events(host.store)]
333
+ assert "fs_read" in types
334
+
335
+ def test_fs_write_event_emitted(self, tmp_path):
336
+ host = _make_host()
337
+ host.invoke("chp.adapters.filesystem.write_file", {
338
+ "path": str(tmp_path / "f.txt"), "content": "x"
339
+ })
340
+ types = [e["event_type"] for e in _cap_events(host.store)]
341
+ assert "fs_write" in types
342
+
343
+ def test_fs_access_denied_event_on_root_violation(self, tmp_path):
344
+ allowed = tmp_path / "sandbox"
345
+ allowed.mkdir()
346
+ other = tmp_path / "other.txt"
347
+ other.write_text("x")
348
+ host = _make_host(FilesystemConfig(allowed_roots=[str(allowed)]))
349
+ host.invoke("chp.adapters.filesystem.read_file", {"path": str(other)})
350
+ types = [e["event_type"] for e in _cap_events(host.store)]
351
+ assert "fs_access_denied" in types
352
+
353
+ def test_no_lifecycle_events_in_evidence(self, tmp_path):
354
+ f = tmp_path / "f.txt"
355
+ f.write_text("x")
356
+ host = _make_host()
357
+ host.invoke("chp.adapters.filesystem.read_file", {"path": str(f)})
358
+ lifecycle = {"execution_started", "execution_completed", "execution_failed"}
359
+ types = {e["event_type"] for e in _cap_events(host.store)}
360
+ assert not types & lifecycle, f"lifecycle events found: {types & lifecycle}"
361
+
362
+
363
+ # --------------------------------------------------------------------------
364
+ # 7. grep
365
+ # --------------------------------------------------------------------------
366
+
367
+ class TestGrep:
368
+ def test_basic_grep(self, tmp_path):
369
+ (tmp_path / "a.py").write_text("def foo():\n pass\n")
370
+ (tmp_path / "b.py").write_text("def bar():\n pass\n")
371
+ host = _make_host()
372
+ r = host.invoke("chp.adapters.filesystem.grep", {
373
+ "pattern": "def foo", "path": str(tmp_path),
374
+ })
375
+ assert r.outcome == "success"
376
+ assert r.data["match_count"] >= 1
377
+ files = [m["file"] for m in r.data["matches"]]
378
+ assert any("a.py" in f for f in files)
379
+
380
+ def test_grep_with_glob_filter(self, tmp_path):
381
+ (tmp_path / "main.py").write_text("SECRET = 'value'\n")
382
+ (tmp_path / "main.txt").write_text("SECRET = 'value'\n")
383
+ host = _make_host()
384
+ r = host.invoke("chp.adapters.filesystem.grep", {
385
+ "pattern": "SECRET", "path": str(tmp_path), "glob": "*.py",
386
+ })
387
+ assert r.outcome == "success"
388
+ files = [m["file"] for m in r.data["matches"]]
389
+ assert all(f.endswith(".py") for f in files)
390
+
391
+ def test_grep_case_insensitive(self, tmp_path):
392
+ (tmp_path / "f.py").write_text("Hello World\n")
393
+ host = _make_host()
394
+ r = host.invoke("chp.adapters.filesystem.grep", {
395
+ "pattern": "hello world", "path": str(tmp_path), "case_insensitive": True,
396
+ })
397
+ assert r.outcome == "success"
398
+ assert r.data["match_count"] >= 1
399
+
400
+ def test_grep_no_matches(self, tmp_path):
401
+ (tmp_path / "f.py").write_text("nothing here\n")
402
+ host = _make_host()
403
+ r = host.invoke("chp.adapters.filesystem.grep", {
404
+ "pattern": "ZZZNOMATCH", "path": str(tmp_path),
405
+ })
406
+ assert r.outcome == "success"
407
+ assert r.data["match_count"] == 0
408
+ assert r.data["truncated"] is False
409
+
410
+ def test_grep_match_text_not_in_evidence(self, tmp_path):
411
+ # The pattern is stored in evidence (correct), but the matched line TEXT
412
+ # from file content must never appear in emitted events.
413
+ (tmp_path / "f.py").write_text("UNIQUE_LINE_CONTENT_99887766\n")
414
+ host = _make_host()
415
+ r = host.invoke("chp.adapters.filesystem.grep", {
416
+ "pattern": "UNIQUE_LINE", "path": str(tmp_path),
417
+ })
418
+ assert r.success
419
+ assert r.data["match_count"] >= 1
420
+ for evt in _cap_events(host.store):
421
+ # match content ("UNIQUE_LINE_CONTENT_99887766") should not appear in events
422
+ assert "UNIQUE_LINE_CONTENT_99887766" not in str(evt.get("payload", {}))
423
+
424
+ def test_grep_denied_outside_allowed_root(self, tmp_path):
425
+ sandbox = tmp_path / "sandbox"
426
+ sandbox.mkdir()
427
+ outside = tmp_path / "outside"
428
+ outside.mkdir()
429
+ (outside / "f.py").write_text("x")
430
+ host = _make_host(FilesystemConfig(allowed_roots=[str(sandbox)]))
431
+ r = host.invoke("chp.adapters.filesystem.grep", {
432
+ "pattern": "x", "path": str(outside),
433
+ })
434
+ assert not r.success
435
+
436
+
437
+ # --------------------------------------------------------------------------
438
+ # 8. glob_files
439
+ # --------------------------------------------------------------------------
440
+
441
+ class TestGlobFiles:
442
+ def test_recursive_glob(self, tmp_path):
443
+ sub = tmp_path / "sub"
444
+ sub.mkdir()
445
+ (tmp_path / "a.py").write_text("")
446
+ (sub / "b.py").write_text("")
447
+ (tmp_path / "c.txt").write_text("")
448
+ host = _make_host()
449
+ r = host.invoke("chp.adapters.filesystem.glob_files", {
450
+ "pattern": "**/*.py", "base_path": str(tmp_path),
451
+ })
452
+ assert r.outcome == "success"
453
+ assert r.data["count"] == 2
454
+ assert r.data["truncated"] is False
455
+ py_files = r.data["files"]
456
+ assert any("a.py" in f for f in py_files)
457
+ assert any("b.py" in f for f in py_files)
458
+
459
+ def test_glob_no_matches(self, tmp_path):
460
+ (tmp_path / "f.txt").write_text("")
461
+ host = _make_host()
462
+ r = host.invoke("chp.adapters.filesystem.glob_files", {
463
+ "pattern": "**/*.zzz", "base_path": str(tmp_path),
464
+ })
465
+ assert r.outcome == "success"
466
+ assert r.data["count"] == 0
467
+
468
+ def test_glob_denied_outside_allowed_root(self, tmp_path):
469
+ sandbox = tmp_path / "sandbox"
470
+ sandbox.mkdir()
471
+ outside = tmp_path / "outside"
472
+ outside.mkdir()
473
+ host = _make_host(FilesystemConfig(allowed_roots=[str(sandbox)]))
474
+ r = host.invoke("chp.adapters.filesystem.glob_files", {
475
+ "pattern": "**/*.py", "base_path": str(outside),
476
+ })
477
+ assert not r.success
478
+
479
+ def test_glob_emits_fs_glob_event(self, tmp_path):
480
+ (tmp_path / "f.py").write_text("")
481
+ host = _make_host()
482
+ host.invoke("chp.adapters.filesystem.glob_files", {
483
+ "pattern": "*.py", "base_path": str(tmp_path),
484
+ })
485
+ types = [e["event_type"] for e in _cap_events(host.store)]
486
+ assert "fs_glob" in types