chp-adapter-process 0.24.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,16 @@
1
+ node_modules/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .chp/
7
+ .DS_Store
8
+ *.tgz
9
+ .env
10
+ .env.*
11
+ *.pyc
12
+ .python-version
13
+ .coverage
14
+ .hypothesis/
15
+ .chp-agent/
16
+ *.sqlite
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: chp-adapter-process
3
+ Version: 0.24.0
4
+ Summary: CHP capability adapter — governed subprocess/CLI execution
5
+ Author: Auxo
6
+ License: Apache-2.0
7
+ Keywords: adapter,capability-host-protocol,chp,cli,process,subprocess
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-asyncio>=0.23; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
File without changes
@@ -0,0 +1,25 @@
1
+ """chp-adapter-process — governed subprocess/CLI execution as a CHP capability.
2
+
3
+ One capability:
4
+
5
+ * ``run`` — execute a command with args; allowlist + timeout + cwd-root enforcement.
6
+ Returns ``{exit_code, stdout, stderr, timed_out, duration_ms}``.
7
+ Evidence: command/args, env_additions keys (not values), exit code, previews.
8
+
9
+ Usage::
10
+
11
+ from chp_core import LocalCapabilityHost, register_adapter
12
+ from chp_adapter_process import ProcessAdapter, ProcessConfig
13
+
14
+ host = LocalCapabilityHost()
15
+ register_adapter(host, ProcessAdapter(ProcessConfig(
16
+ allowed_commands=["echo", "ls"],
17
+ max_timeout=10.0,
18
+ )))
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .adapter import ProcessAdapter, ProcessConfig
24
+
25
+ __all__ = ["ProcessAdapter", "ProcessConfig"]
@@ -0,0 +1,245 @@
1
+ """ProcessAdapter — governed subprocess/CLI execution as a CHP capability.
2
+
3
+ Safety invariants (MUST PRESERVE):
4
+ * ``shell=False`` always — prevents shell injection via args.
5
+ * Command must be in ``allowed_commands`` if the list is non-None.
6
+ * ``cwd`` must resolve under ``working_dir`` if one is configured.
7
+ * Timeout enforced; process killed (SIGKILL) on expiry.
8
+ * ``env_additions`` keys in evidence, values never (may carry secrets).
9
+ * Full stdout/stderr returned as data; only 500-char previews in evidence.
10
+
11
+ One capability: ``run``
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import os
18
+ import time as _time
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from chp_core import BaseAdapter, capability
24
+
25
+ _EMITS = ["process_start", "process_result", "process_timeout", "process_error"]
26
+
27
+ _PREVIEW_LEN = 500
28
+
29
+
30
+ def _is_within(path: Path, root: Path) -> bool:
31
+ """True if *path* is *root* or lives under it — component-wise, not string
32
+ prefix (so 'working_dir-evil' is not 'within' 'working_dir')."""
33
+ try:
34
+ return path == root or path.is_relative_to(root)
35
+ except AttributeError: # Python < 3.9
36
+ try:
37
+ return os.path.commonpath([str(path), str(root)]) == str(root)
38
+ except ValueError:
39
+ return False
40
+
41
+
42
+ @dataclass
43
+ class ProcessConfig:
44
+ """Config for ProcessAdapter.
45
+
46
+ ``allowed_commands`` — if non-None, only these command names (no path
47
+ required) may be executed. Pass ``None`` to allow all (use carefully).
48
+
49
+ ``working_dir`` — if set, ``cwd`` in payloads must resolve under this
50
+ root. Defaults to no restriction.
51
+
52
+ ``max_timeout`` — hard upper bound on any per-call ``timeout`` value.
53
+ """
54
+
55
+ allowed_commands: list[str] | None = None
56
+ working_dir: str | None = None
57
+ max_timeout: float = 60.0
58
+ max_output_bytes: int = 64 * 1024
59
+
60
+
61
+ class ProcessAdapter(BaseAdapter):
62
+ """Governed subprocess/CLI execution."""
63
+
64
+ adapter_id = "chp.adapters.process"
65
+ adapter_name = "Process"
66
+ adapter_description = "Execute CLI commands with governed allowlist and timeout enforcement."
67
+ adapter_category = "execution"
68
+ adapter_tags = ["process", "subprocess", "cli", "execution"]
69
+
70
+ def __init__(self, config: ProcessConfig | None = None) -> None:
71
+ self._config = config or ProcessConfig()
72
+
73
+ # ------------------------------------------------------------------
74
+ # run
75
+ # ------------------------------------------------------------------
76
+
77
+ @capability(
78
+ id="chp.adapters.process.run",
79
+ version="1.0.0",
80
+ description="Execute a CLI command with arguments.",
81
+ category="execution",
82
+ risk="high",
83
+ input_schema={
84
+ "type": "object",
85
+ "properties": {
86
+ "command": {"type": "string", "description": "Command to execute (no shell)."},
87
+ "args": {
88
+ "type": "array",
89
+ "items": {"type": "string"},
90
+ "description": "Command-line arguments.",
91
+ },
92
+ "cwd": {
93
+ "type": "string",
94
+ "description": "Working directory (must be under working_dir if configured).",
95
+ },
96
+ "timeout": {
97
+ "type": "number",
98
+ "minimum": 0.1,
99
+ "description": "Timeout in seconds (capped at max_timeout).",
100
+ },
101
+ "env_additions": {
102
+ "type": "object",
103
+ "description": "Additional environment variables (keys in evidence, values not).",
104
+ "additionalProperties": {"type": "string"},
105
+ },
106
+ },
107
+ "required": ["command"],
108
+ "additionalProperties": False,
109
+ },
110
+ emits=_EMITS,
111
+ tags=["process", "cli"],
112
+ )
113
+ async def run(self, ctx: Any, payload: dict) -> dict:
114
+ command = payload["command"]
115
+ args = payload.get("args") or []
116
+ cwd_raw = payload.get("cwd")
117
+ timeout = min(
118
+ float(payload.get("timeout") or self._config.max_timeout),
119
+ self._config.max_timeout,
120
+ )
121
+ env_additions = payload.get("env_additions") or {}
122
+
123
+ # --- allowlist check ---
124
+ cfg = self._config
125
+ if cfg.allowed_commands is not None and command not in cfg.allowed_commands:
126
+ ctx.emit("process_error", {
127
+ "reason": "command_not_allowed",
128
+ "command": command,
129
+ }, redacted=False)
130
+ raise PermissionError(f"Command {command!r} is not in allowed_commands")
131
+
132
+ # --- cwd check ---
133
+ cwd: str | None = None
134
+ if cwd_raw is not None:
135
+ resolved_cwd = Path(cwd_raw).resolve()
136
+ if cfg.working_dir is not None:
137
+ working_root = Path(cfg.working_dir).resolve()
138
+ # Component-wise containment, not string prefix: a sibling like
139
+ # 'working_dir-evil' must not pass as inside 'working_dir'.
140
+ if not _is_within(resolved_cwd, working_root):
141
+ ctx.emit("process_error", {
142
+ "reason": "cwd_outside_working_dir",
143
+ "cwd": str(resolved_cwd),
144
+ }, redacted=False)
145
+ raise PermissionError(
146
+ f"cwd {resolved_cwd} is outside working_dir {cfg.working_dir!r}"
147
+ )
148
+ cwd = str(resolved_cwd)
149
+
150
+ # --- environment ---
151
+ env = dict(os.environ)
152
+ if env_additions:
153
+ env.update(env_additions)
154
+
155
+ ctx.emit("process_start", {
156
+ "command": command,
157
+ "args": args,
158
+ "cwd": cwd,
159
+ "timeout": timeout,
160
+ "env_additions_keys": sorted(env_additions.keys()),
161
+ # env_additions values intentionally not recorded
162
+ }, redacted=False)
163
+
164
+ t0 = _time.monotonic()
165
+ timed_out = False
166
+ stdout_str = ""
167
+ stderr_str = ""
168
+ exit_code = -1
169
+
170
+ try:
171
+ proc = await asyncio.create_subprocess_exec(
172
+ command, *args,
173
+ cwd=cwd,
174
+ env=env,
175
+ stdout=asyncio.subprocess.PIPE,
176
+ stderr=asyncio.subprocess.PIPE,
177
+ )
178
+ try:
179
+ raw_stdout, raw_stderr = await asyncio.wait_for(
180
+ proc.communicate(), timeout=timeout
181
+ )
182
+ exit_code = proc.returncode if proc.returncode is not None else -1
183
+ except asyncio.TimeoutError:
184
+ timed_out = True
185
+ try:
186
+ proc.kill()
187
+ except ProcessLookupError:
188
+ pass
189
+ await proc.communicate()
190
+ exit_code = -1
191
+
192
+ except FileNotFoundError:
193
+ duration_ms = int((_time.monotonic() - t0) * 1000)
194
+ ctx.emit("process_error", {
195
+ "command": command,
196
+ "reason": "command_not_found",
197
+ "duration_ms": duration_ms,
198
+ }, redacted=False)
199
+ raise
200
+
201
+ except Exception as exc:
202
+ duration_ms = int((_time.monotonic() - t0) * 1000)
203
+ ctx.emit("process_error", {
204
+ "command": command,
205
+ "reason": type(exc).__name__,
206
+ "error": str(exc)[:200],
207
+ "duration_ms": duration_ms,
208
+ }, redacted=False)
209
+ raise
210
+
211
+ duration_ms = int((_time.monotonic() - t0) * 1000)
212
+
213
+ if not timed_out:
214
+ stdout_bytes = raw_stdout or b""
215
+ stderr_bytes = raw_stderr or b""
216
+ # Truncate to max_output_bytes before decoding
217
+ if len(stdout_bytes) > cfg.max_output_bytes:
218
+ stdout_bytes = stdout_bytes[: cfg.max_output_bytes]
219
+ if len(stderr_bytes) > cfg.max_output_bytes:
220
+ stderr_bytes = stderr_bytes[: cfg.max_output_bytes]
221
+ stdout_str = stdout_bytes.decode(errors="replace")
222
+ stderr_str = stderr_bytes.decode(errors="replace")
223
+
224
+ if timed_out:
225
+ ctx.emit("process_timeout", {
226
+ "command": command,
227
+ "timeout": timeout,
228
+ "duration_ms": duration_ms,
229
+ }, redacted=False)
230
+ else:
231
+ ctx.emit("process_result", {
232
+ "command": command,
233
+ "exit_code": exit_code,
234
+ "stdout_preview": stdout_str[:_PREVIEW_LEN],
235
+ "stderr_preview": stderr_str[:_PREVIEW_LEN],
236
+ "duration_ms": duration_ms,
237
+ }, redacted=False)
238
+
239
+ return {
240
+ "exit_code": exit_code,
241
+ "stdout": stdout_str,
242
+ "stderr": stderr_str,
243
+ "timed_out": timed_out,
244
+ "duration_ms": duration_ms,
245
+ }
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chp-adapter-process"
7
+ version = "0.24.0"
8
+ description = "CHP capability adapter — governed subprocess/CLI execution"
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", "process", "subprocess", "cli", "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
+ process = "chp_adapter_process:ProcessAdapter"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ pythonpath = ["."]
38
+ asyncio_mode = "auto"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["chp_adapter_process"]
@@ -0,0 +1,282 @@
1
+ """Tests for chp_adapter_process.adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ from chp_core import LocalCapabilityHost, register_adapter
10
+ from chp_core.store import SQLiteEvidenceStore
11
+
12
+ from chp_adapter_process import ProcessAdapter, ProcessConfig
13
+
14
+
15
+ # --------------------------------------------------------------------------
16
+ # Helpers
17
+ # --------------------------------------------------------------------------
18
+
19
+ def _make_host(config=None):
20
+ host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
21
+ register_adapter(host, ProcessAdapter(config))
22
+ return host
23
+
24
+
25
+ def _cap_events(store):
26
+ return [e for e in store.all() if "capability_uri" not in e["payload"]]
27
+
28
+
29
+ _PYTHON = sys.executable # path to current Python interpreter
30
+
31
+
32
+ # --------------------------------------------------------------------------
33
+ # 1. Shaping
34
+ # --------------------------------------------------------------------------
35
+
36
+ class TestShaping:
37
+ def test_one_capability(self):
38
+ ids = {c.descriptor.id for c in ProcessAdapter().capabilities()}
39
+ assert ids == {"chp.adapters.process.run"}
40
+
41
+ def test_run_is_high_risk(self):
42
+ caps = {c.descriptor.id: c.descriptor for c in ProcessAdapter().capabilities()}
43
+ assert caps["chp.adapters.process.run"].risk == "high"
44
+
45
+ def test_adapter_id(self):
46
+ assert ProcessAdapter.adapter_id == "chp.adapters.process"
47
+
48
+
49
+ # --------------------------------------------------------------------------
50
+ # 2. Success path
51
+ # --------------------------------------------------------------------------
52
+
53
+ class TestSuccessPath:
54
+ def test_echo_succeeds(self):
55
+ host = _make_host()
56
+ r = host.invoke("chp.adapters.process.run", {
57
+ "command": _PYTHON, "args": ["-c", "print('hello')"]
58
+ })
59
+ assert r.outcome == "success"
60
+ assert r.data["exit_code"] == 0
61
+ assert "hello" in r.data["stdout"]
62
+ assert r.data["timed_out"] is False
63
+
64
+ def test_exit_code_captured(self):
65
+ host = _make_host()
66
+ r = host.invoke("chp.adapters.process.run", {
67
+ "command": _PYTHON, "args": ["-c", "import sys; sys.exit(42)"]
68
+ })
69
+ assert r.outcome == "success"
70
+ assert r.data["exit_code"] == 42
71
+
72
+ def test_stderr_captured(self):
73
+ host = _make_host()
74
+ r = host.invoke("chp.adapters.process.run", {
75
+ "command": _PYTHON,
76
+ "args": ["-c", "import sys; sys.stderr.write('err-output')"]
77
+ })
78
+ assert r.outcome == "success"
79
+ assert "err-output" in r.data["stderr"]
80
+
81
+ def test_duration_ms_positive(self):
82
+ host = _make_host()
83
+ r = host.invoke("chp.adapters.process.run", {
84
+ "command": _PYTHON, "args": ["-c", "pass"]
85
+ })
86
+ assert r.data["duration_ms"] >= 0
87
+
88
+
89
+ # --------------------------------------------------------------------------
90
+ # 3. Allowlist
91
+ # --------------------------------------------------------------------------
92
+
93
+ class TestAllowlist:
94
+ def test_command_not_in_allowlist_fails(self):
95
+ host = _make_host(ProcessConfig(allowed_commands=["echo"]))
96
+ r = host.invoke("chp.adapters.process.run", {"command": _PYTHON, "args": ["-c", "pass"]})
97
+ assert r.outcome == "failure"
98
+
99
+ def test_command_in_allowlist_succeeds(self):
100
+ host = _make_host(ProcessConfig(allowed_commands=[_PYTHON]))
101
+ r = host.invoke("chp.adapters.process.run", {
102
+ "command": _PYTHON, "args": ["-c", "pass"]
103
+ })
104
+ assert r.outcome == "success"
105
+
106
+ def test_none_allowlist_permits_all(self):
107
+ host = _make_host(ProcessConfig(allowed_commands=None))
108
+ r = host.invoke("chp.adapters.process.run", {
109
+ "command": _PYTHON, "args": ["-c", "pass"]
110
+ })
111
+ assert r.outcome == "success"
112
+
113
+
114
+ # --------------------------------------------------------------------------
115
+ # 4. Timeout
116
+ # --------------------------------------------------------------------------
117
+
118
+ class TestTimeout:
119
+ def test_timeout_kills_process(self):
120
+ host = _make_host(ProcessConfig(max_timeout=0.5))
121
+ r = host.invoke("chp.adapters.process.run", {
122
+ "command": _PYTHON,
123
+ "args": ["-c", "import time; time.sleep(10)"],
124
+ "timeout": 0.3,
125
+ })
126
+ assert r.outcome == "success"
127
+ assert r.data["timed_out"] is True
128
+ assert r.data["exit_code"] == -1
129
+
130
+ def test_timeout_capped_at_max(self):
131
+ host = _make_host(ProcessConfig(max_timeout=5.0))
132
+ r = host.invoke("chp.adapters.process.run", {
133
+ "command": _PYTHON, "args": ["-c", "pass"],
134
+ "timeout": 999.0,
135
+ })
136
+ assert r.outcome == "success"
137
+ assert r.data["timed_out"] is False
138
+
139
+
140
+ # --------------------------------------------------------------------------
141
+ # 5. cwd restriction
142
+ # --------------------------------------------------------------------------
143
+
144
+ class TestCwdRestriction:
145
+ def test_cwd_outside_working_dir_fails(self, tmp_path):
146
+ allowed = tmp_path / "allowed"
147
+ allowed.mkdir()
148
+ host = _make_host(ProcessConfig(working_dir=str(allowed)))
149
+ r = host.invoke("chp.adapters.process.run", {
150
+ "command": _PYTHON, "args": ["-c", "pass"],
151
+ "cwd": str(tmp_path),
152
+ })
153
+ assert r.outcome == "failure"
154
+
155
+ def test_cwd_inside_working_dir_succeeds(self, tmp_path):
156
+ host = _make_host(ProcessConfig(working_dir=str(tmp_path)))
157
+ r = host.invoke("chp.adapters.process.run", {
158
+ "command": _PYTHON, "args": ["-c", "pass"],
159
+ "cwd": str(tmp_path),
160
+ })
161
+ assert r.outcome == "success"
162
+
163
+
164
+ # --------------------------------------------------------------------------
165
+ # 6. Unknown command
166
+ # --------------------------------------------------------------------------
167
+
168
+ class TestUnknownCommand:
169
+ def test_command_not_found_fails(self):
170
+ host = _make_host()
171
+ r = host.invoke("chp.adapters.process.run", {
172
+ "command": "definitely_not_a_real_command_xyz123"
173
+ })
174
+ assert r.outcome == "failure"
175
+
176
+
177
+ # --------------------------------------------------------------------------
178
+ # 7. Schema validation
179
+ # --------------------------------------------------------------------------
180
+
181
+ class TestSchema:
182
+ def test_missing_command_denied(self):
183
+ host = _make_host()
184
+ r = host.invoke("chp.adapters.process.run", {})
185
+ assert r.outcome == "denied"
186
+
187
+ def test_extra_field_denied(self):
188
+ host = _make_host()
189
+ r = host.invoke("chp.adapters.process.run", {
190
+ "command": _PYTHON, "injected": "bad"
191
+ })
192
+ assert r.outcome == "denied"
193
+
194
+ def test_valid_command_not_denied(self):
195
+ # Regression for rad:72fb420 — valid command must not be denied.
196
+ host = _make_host()
197
+ r = host.invoke("chp.adapters.process.run",
198
+ {"command": _PYTHON, "args": ["-c", "pass"]})
199
+ assert r.outcome == "success"
200
+
201
+ def test_valid_payload_via_arguments_key(self):
202
+ # from_mapping supports 'arguments' as alias for 'payload'.
203
+ from chp_core.types import InvocationEnvelope
204
+ env = InvocationEnvelope.from_mapping({
205
+ "capability_id": "chp.adapters.process.run",
206
+ "arguments": {"command": _PYTHON, "args": ["-c", "pass"]},
207
+ })
208
+ assert env.payload == {"command": _PYTHON, "args": ["-c", "pass"]}
209
+
210
+
211
+ # --------------------------------------------------------------------------
212
+ # 8. Evidence hygiene
213
+ # --------------------------------------------------------------------------
214
+
215
+ class TestEvidenceHygiene:
216
+ def test_env_additions_values_not_in_evidence(self):
217
+ host = _make_host()
218
+ host.invoke("chp.adapters.process.run", {
219
+ "command": _PYTHON, "args": ["-c", "pass"],
220
+ "env_additions": {"MY_SECRET": "SUPER_SECRET_VALUE_XYZ"},
221
+ })
222
+ dump = str([e["payload"] for e in _cap_events(host.store)])
223
+ assert "SUPER_SECRET_VALUE_XYZ" not in dump
224
+
225
+ def test_env_additions_keys_in_evidence(self):
226
+ host = _make_host()
227
+ host.invoke("chp.adapters.process.run", {
228
+ "command": _PYTHON, "args": ["-c", "pass"],
229
+ "env_additions": {"MY_SECRET": "value"},
230
+ })
231
+ dump = str([e["payload"] for e in _cap_events(host.store)])
232
+ assert "MY_SECRET" in dump
233
+
234
+ def test_process_start_event_emitted(self):
235
+ host = _make_host()
236
+ host.invoke("chp.adapters.process.run", {
237
+ "command": _PYTHON, "args": ["-c", "pass"]
238
+ })
239
+ types = [e["event_type"] for e in _cap_events(host.store)]
240
+ assert "process_start" in types
241
+
242
+ def test_process_result_event_emitted(self):
243
+ host = _make_host()
244
+ host.invoke("chp.adapters.process.run", {
245
+ "command": _PYTHON, "args": ["-c", "pass"]
246
+ })
247
+ types = [e["event_type"] for e in _cap_events(host.store)]
248
+ assert "process_result" in types
249
+
250
+ def test_process_timeout_event_emitted(self):
251
+ host = _make_host(ProcessConfig(max_timeout=0.5))
252
+ host.invoke("chp.adapters.process.run", {
253
+ "command": _PYTHON,
254
+ "args": ["-c", "import time; time.sleep(10)"],
255
+ "timeout": 0.3,
256
+ })
257
+ types = [e["event_type"] for e in _cap_events(host.store)]
258
+ assert "process_timeout" in types
259
+
260
+ def test_no_lifecycle_events_in_evidence(self):
261
+ host = _make_host()
262
+ host.invoke("chp.adapters.process.run", {
263
+ "command": _PYTHON, "args": ["-c", "pass"]
264
+ })
265
+ lifecycle = {"execution_started", "execution_completed", "execution_failed"}
266
+ types = {e["event_type"] for e in _cap_events(host.store)}
267
+ assert not types & lifecycle, f"lifecycle events found: {types & lifecycle}"
268
+
269
+ def test_stdout_preview_truncated_in_evidence(self):
270
+ big_output = "A" * 2000
271
+ host = _make_host()
272
+ host.invoke("chp.adapters.process.run", {
273
+ "command": _PYTHON,
274
+ "args": ["-c", f"print('{'A' * 2000}')"],
275
+ })
276
+ result_events = [
277
+ e for e in _cap_events(host.store)
278
+ if e["event_type"] == "process_result"
279
+ ]
280
+ assert len(result_events) == 1
281
+ preview = result_events[0]["payload"].get("stdout_preview", "")
282
+ assert len(preview) <= 500