chp-adapter-process 0.23.0__py3-none-any.whl

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,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,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: chp-adapter-process
3
+ Version: 0.23.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'
@@ -0,0 +1,6 @@
1
+ chp_adapter_process/__init__.py,sha256=FRsZfv5JMKHwhAzdxfvk3qPDVMVKIsFNxJcsT7jndMw,778
2
+ chp_adapter_process/adapter.py,sha256=zDNZEszVzFaqHoNTWwRG2FQeXRkWrvjm5TELMPs9AwI,8857
3
+ chp_adapter_process-0.23.0.dist-info/METADATA,sha256=jtTjWxHkLhoce9g1QK7FcAXqZEiIJVt4UajsVFux7wA,883
4
+ chp_adapter_process-0.23.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ chp_adapter_process-0.23.0.dist-info/entry_points.txt,sha256=UjG3aT7Am5CML2Q_6pHBnh65m-1ZulgSbWPOUWY19zw,60
6
+ chp_adapter_process-0.23.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [chp.adapters]
2
+ process = chp_adapter_process:ProcessAdapter