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,,
|