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.
- chp_adapter_filesystem-0.11.0/.gitignore +36 -0
- chp_adapter_filesystem-0.11.0/PKG-INFO +20 -0
- chp_adapter_filesystem-0.11.0/README.md +0 -0
- chp_adapter_filesystem-0.11.0/chp_adapter_filesystem/__init__.py +25 -0
- chp_adapter_filesystem-0.11.0/chp_adapter_filesystem/adapter.py +508 -0
- chp_adapter_filesystem-0.11.0/pyproject.toml +40 -0
- chp_adapter_filesystem-0.11.0/tests/test_filesystem_adapter.py +486 -0
|
@@ -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
|