Skip to content

Commit 823b682

Browse files
committed
Merge branch 'feature/execute_cli_command' into 'master'
feat(serial_handler): Support executing CLI commands from monitor logs See merge request espressif/esp-idf-monitor!100
2 parents ded1b00 + ebf5699 commit 823b682

File tree

4 files changed

+328
-0
lines changed

4 files changed

+328
-0
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Other advanced topics like configuration file will be described in the following
1919
- [Custom Reset Sequence](#custom-reset-sequence)
2020
- [Share Configuration Across Tools](#share-configuration-across-tools)
2121
- [Syntax](#syntax)
22+
- [Embedded Command Execution](#embedded-command-execution)
2223

2324
## Installation
2425

@@ -169,6 +170,54 @@ exit_menu_key = X
169170
skip_menu_key = False
170171
```
171172

173+
## Embedded Command Execution
174+
175+
`esp-idf-monitor` includes an advanced feature that automatically executes host-side tools when the target device outputs specific markers in its logs. This is particularly useful for workflows where device firmware needs to perform operations that are better handled on the host computer—such as decoding or analyzing chip data (for example, reading and interpreting eFuse dump).
176+
177+
### How It Works
178+
179+
When the monitor detects one of the predefined markers in the device output, it automatically executes the corresponding command template. The command substitutes data from the device output (such as eFuse tokens) into the template, allowing seamless data analysis without manual intervention.
180+
181+
### Supported Markers
182+
183+
The following markers are currently supported:
184+
185+
| Marker | Command Template | Use Case |
186+
|----------------------------------------|--------------------------------------------|------------------------------------|
187+
| `IDF_MONITOR_EXECUTE_ESPEFUSE_SUMMARY` | `espefuse --token {ARGS} summary --active` | Display active eFuse summary |
188+
| `IDF_MONITOR_EXECUTE_ESPEFUSE_DUMP` | `espefuse --token {ARGS} dump` | Display eFuse dump |
189+
190+
For both commands, `{ARGS}` must include:
191+
- A token eFuse dump (format: `EFSR:chiptype:size:hexdata...`)
192+
- Optionally, additional flags such as `--extend-efuse-table main/esp_efuse_custom_table.csv` to extend eFuse field definitions
193+
194+
### Usage Example
195+
196+
When your firmware outputs a line containing `IDF_MONITOR_EXECUTE_ESPEFUSE_DUMP`:
197+
198+
```text
199+
I (481) example: IDF_MONITOR_EXECUTE_ESPEFUSE_DUMP EFSR:esp32c3:100:AAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAA:zIH3-VVgAAAAAAAAAAAAS8kmEVKwQgYB:ZSd8yloMSAJssOWmfZQw8lFbphuTZH574QcV3ggAAAA:AAAAAAAAAAEayAcAAAAAAAAAAAAAAAAAAAAAAAAAAAA:::::::::ydrNkQ
200+
--- Executing monitor command: espefuse --token EFSR:esp32c3:100:... dump
201+
espefuse v5.1.0
202+
=== Run "dump" command ===
203+
BLOCK0 ( ) [0 ] dump: 00000000 00000000 00000000 00000000 80000000 00000000
204+
MAC_SPI_8M_0 (BLOCK1 ) [1 ] dump: f9f781cc 00006055 00000000 4b000000 521126c9 010642b0
205+
BLOCK_SYS_DATA (BLOCK2 ) [2 ] dump: ca7c2765 02480c5a a6e5b06c f230947d 1ba65b51 7b7e6493 de1507e1 00000008
206+
...
207+
I (331) example: read efuse fields
208+
```
209+
210+
### Security and Limitations
211+
212+
For your security and to ensure predictable behavior, IDF Monitor:
213+
214+
- Does not execute arbitrary commands printed by the device
215+
- Supports only a small, predefined set of markers mapped to fixed command templates
216+
- Accepts only `<ARGS>` from the device—the eFuse token and optional flags—which are substituted into the template
217+
- Executes all commands with `shell=False`, preventing shell metacharacters (`&&`, `;`, `|`, `>`) from being interpreted
218+
219+
This intentional limitation ensures that only specific, safe espefuse operations are available. Any future extensions would require careful review for security implications.
220+
172221
## Contributing
173222

174223
### Code Style & Static Analysis
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""
5+
Secure command executor for monitor-triggered CLI commands.
6+
7+
This module provides a controlled interface for executing pre-approved commands
8+
found in monitor log lines, while preventing arbitrary code execution.
9+
10+
Expected format in the log line:
11+
12+
... IDF_MONITOR_EXECUTE_<TYPE> <ARGS>
13+
14+
For example:
15+
16+
I (311) example: IDF_MONITOR_EXECUTE_ESPEFUSE_SUMMARY EFSR:esp32:300:AAAA...
17+
"""
18+
19+
import os
20+
import shlex
21+
import subprocess
22+
23+
from .output_helpers import note_print
24+
from .output_helpers import warning_print
25+
26+
# Prefix used in log lines to indicate an executable monitor command
27+
MONITOR_EXECUTE_PREFIX = 'IDF_MONITOR_EXECUTE_'
28+
29+
30+
class SecureMonitorCommandExecutor:
31+
"""
32+
Secure executor for commands embedded in monitor log lines.
33+
34+
The executor:
35+
- parses log lines for MONITOR_EXECUTE_PREFIX,
36+
- extracts the command type and arguments that follow the marker,
37+
- maps the type to a fixed command template,
38+
- executes the command if it is allowed.
39+
40+
This prevents arbitrary commands from being run based solely on device output.
41+
"""
42+
43+
def __init__(self, logger) -> None:
44+
"""
45+
Initialize the command executor.
46+
47+
Args:
48+
logger: Logger instance used to print command output.
49+
"""
50+
self._logger = logger
51+
self._incomplete_line = ''
52+
self.enable = True
53+
# Allowed commands keyed by TYPE after IDF_MONITOR_EXECUTE_
54+
# The placeholder '{}' is filled with the argument string from the log line
55+
self._allowed_cmds = {
56+
'ESPEFUSE_SUMMARY': 'espefuse --token {} summary --active',
57+
'ESPEFUSE_DUMP': 'espefuse --token {} dump',
58+
}
59+
60+
def execute_from_log_line(self, chunk: bytes) -> None:
61+
"""
62+
Consume a chunk of bytes from the monitor and execute an embedded
63+
command if a *complete* line with MONITOR_EXECUTE_PREFIX is present.
64+
65+
This function is robust to partial lines: it accumulates data until
66+
a newline is seen, then processes whole lines one by one.
67+
"""
68+
if not self.enable:
69+
return
70+
71+
# log output is ASCII-like; ignore any problematic bytes
72+
text = chunk.decode('ascii', errors='ignore')
73+
74+
if '\n' not in text:
75+
# No complete line yet; accumulate
76+
self._incomplete_line += text
77+
return
78+
79+
# Complete line(s) present
80+
if self._incomplete_line:
81+
text = self._incomplete_line + text
82+
self._incomplete_line = ''
83+
84+
self._process_complete_line(text)
85+
86+
def _process_complete_line(self, line_text: str) -> None:
87+
"""
88+
Handle a single *complete* log line (without trailing newline).
89+
If it contains a valid IDF_MONITOR_EXECUTE_<TYPE> command, execute it.
90+
"""
91+
92+
if MONITOR_EXECUTE_PREFIX not in line_text:
93+
return
94+
95+
# Extract everything after the first occurrence of "IDF_MONITOR_EXECUTE_"
96+
parts = line_text.split(MONITOR_EXECUTE_PREFIX, 1)
97+
if len(parts) < 2:
98+
return
99+
100+
# Split into TYPE and the rest (arguments)
101+
try:
102+
cmd_type, cmd_args = parts[1].strip().split(maxsplit=1)
103+
except ValueError:
104+
cmd_type, cmd_args = parts[1].strip(), ''
105+
106+
template = self._allowed_cmds.get(cmd_type)
107+
if template is None: # Unknown cmd type
108+
warning_print(f'Ignoring unknown monitor command type: "IDF_MONITOR_EXECUTE_{cmd_type}"')
109+
return
110+
111+
if '{}' in template:
112+
cmd_args = cmd_args.strip()
113+
if not cmd_args:
114+
warning_print(f'Ignoring monitor command: no arguments provided for command "{cmd_type}": "{template}"')
115+
return
116+
formatted = template.format(cmd_args)
117+
else:
118+
formatted = template
119+
120+
cmd_argv = shlex.split(formatted) # Convert to argv list
121+
122+
note_print(f'Executing monitor command: {" ".join(cmd_argv)}')
123+
124+
try:
125+
output = subprocess.check_output(cmd_argv, stderr=subprocess.STDOUT, env=os.environ, shell=False)
126+
self._logger.print(output)
127+
except subprocess.CalledProcessError as e:
128+
warning_print(f'Command failed: {e}\n{e.output.decode(errors="ignore")}')
129+
except OSError as e:
130+
warning_print(f'Failed to execute command: {e}')

esp_idf_monitor/base/serial_handler.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from .key_config import MENU_KEY
4545
from .line_matcher import LineMatcher # noqa: F401
4646
from .logger import Logger # noqa: F401
47+
from .monitor_secure_exec import SecureMonitorCommandExecutor
4748
from .output_helpers import ANSI_GREEN_B
4849
from .output_helpers import ANSI_NORMAL_B
4950
from .output_helpers import ANSI_RED_B
@@ -123,6 +124,7 @@ def __init__(
123124
self.disable_auto_color = disable_auto_color
124125
self.binlog = BinaryLog(elf_files)
125126
self.binary_log_detected = False
127+
self.monitor_cmd_executor = SecureMonitorCommandExecutor(self.logger)
126128

127129
def splitdata(self, data): # type: (bytes) -> List[bytes]
128130
"""
@@ -207,6 +209,7 @@ def handle_serial_input(
207209
for line in text_lines:
208210
self.print_colored(line)
209211
self.logger.handle_possible_pc_address_in_line(line)
212+
self.monitor_cmd_executor.execute_from_log_line(line)
210213
return
211214
except ValueError:
212215
# If no valid binary log frames were found, or if we have too much accumulated data
@@ -241,6 +244,7 @@ def handle_serial_input(
241244
self.print_colored(line)
242245
self.compare_elf_sha256(decoded_line)
243246
self.logger.handle_possible_pc_address_in_line(line_strip)
247+
self.monitor_cmd_executor.execute_from_log_line(line)
244248
check_gdb_stub_and_run(line_strip)
245249
self._force_line_print = False
246250

@@ -262,6 +266,7 @@ def handle_serial_input(
262266
self._force_line_print = True
263267
self.print_colored(self._last_line_part)
264268
self.logger.handle_possible_pc_address_in_line(self._last_line_part, insert_new_line=True)
269+
self.monitor_cmd_executor.execute_from_log_line(self._last_line_part)
265270
check_gdb_stub_and_run(self._last_line_part)
266271
# It is possible that the incomplete line cuts in half the PC
267272
# address. A small buffer is kept and will be used the next time
@@ -393,6 +398,7 @@ def handle_serial_input(
393398

394399
if self._force_line_print or line_matcher.match(line.decode(errors='ignore')):
395400
self.print_colored(line)
401+
self.monitor_cmd_executor.execute_from_log_line(line)
396402
self._force_line_print = False
397403

398404
if self._last_line_part.startswith(CONSOLE_STATUS_QUERY):
@@ -409,4 +415,5 @@ def handle_serial_input(
409415
if self._last_line_part != b'' and force_print_or_matched:
410416
self._force_line_print = True
411417
self.print_colored(self._last_line_part)
418+
self.monitor_cmd_executor.execute_from_log_line(self._last_line_part)
412419
self._last_line_part = b''

test/host_test/test_monitor.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,3 +667,145 @@ def test_c_format(self, c_fmt, arg, pythonic_fmt, output):
667667
assert converted_format == pythonic_fmt, f"Expected Pythonic format '{pythonic_fmt}', got '{converted_format}'"
668668
formatted_output = formatter.c_format(c_fmt, [arg])
669669
assert formatted_output == output, f"Expected '{output}', got '{formatted_output}'"
670+
671+
672+
class TestEmbeddedMonitorCommands:
673+
"""Tests for SecureMonitorCommandExecutor handling embedded monitor commands."""
674+
675+
class DummyLogger:
676+
def __init__(self) -> None:
677+
self.outputs: List[bytes] = []
678+
679+
def print(self, data: bytes) -> None:
680+
self.outputs.append(data)
681+
682+
@pytest.mark.parametrize(
683+
'input_line, expect_called, expected_argv',
684+
[
685+
# Unknown marker type after IDF_MONITOR_EXECUTE_: should be ignored
686+
(
687+
'I (20) test: IDF_MONITOR_EXECUTE_UNKNOWN EFSR:esp32c3:100:AAA\n',
688+
False,
689+
None,
690+
),
691+
# Marker without any arguments: should be ignored
692+
(
693+
'I (20) test: IDF_MONITOR_EXECUTE_ESPEFUSE_SUMMARY\n',
694+
False,
695+
None,
696+
),
697+
# Valid ESPEFUSE_SUMMARY with token
698+
(
699+
'I (311) example: IDF_MONITOR_EXECUTE_ESPEFUSE_SUMMARY EFSR:esp32c3:100:AAA\n',
700+
True,
701+
['espefuse', '--token', 'EFSR:esp32c3:100:AAA', 'summary', '--active'],
702+
),
703+
# Valid ESPEFUSE_DUMP with token
704+
(
705+
'I (331) example: IDF_MONITOR_EXECUTE_ESPEFUSE_DUMP EFSR:esp32c3:100:AAA\n',
706+
True,
707+
['espefuse', '--token', 'EFSR:esp32c3:100:AAA', 'dump'],
708+
),
709+
],
710+
)
711+
def test_monitor_embedded_command_execution(
712+
self,
713+
input_line: str,
714+
expect_called: bool,
715+
expected_argv: Optional[List[str]],
716+
monkeypatch,
717+
):
718+
"""
719+
Verify that SecureMonitorCommandExecutor:
720+
"""
721+
from esp_idf_monitor.base.monitor_secure_exec import SecureMonitorCommandExecutor
722+
723+
calls = []
724+
725+
def fake_check_output(argv, stderr=None, env=None, shell=None):
726+
calls.append(
727+
{
728+
'argv': argv,
729+
'stderr': stderr,
730+
'env': env,
731+
'shell': shell,
732+
}
733+
)
734+
return b'OK\n'
735+
736+
# Patch subprocess.check_output used inside monitor_secure_exec
737+
monkeypatch.setattr(
738+
'esp_idf_monitor.base.monitor_secure_exec.subprocess.check_output',
739+
fake_check_output,
740+
)
741+
742+
logger = self.DummyLogger()
743+
executor = SecureMonitorCommandExecutor(logger)
744+
745+
# Run executor for this test case (single full line, with '\n')
746+
executor.execute_from_log_line(input_line.encode('ascii'))
747+
748+
if not expect_called:
749+
assert calls == []
750+
assert logger.outputs == []
751+
return
752+
753+
# Exactly one subprocess call expected
754+
assert len(calls) == 1
755+
call = calls[0]
756+
argv = call['argv']
757+
758+
# Full argv must match the expected expansion of the template
759+
assert argv == expected_argv
760+
761+
# Explicitly verify that shell=False was used
762+
assert call['shell'] is False
763+
764+
# Logger should receive the output from the subprocess
765+
assert logger.outputs == [b'OK\n']
766+
767+
def test_monitor_embedded_command_streaming_chunks(self, monkeypatch):
768+
"""
769+
Verify that execute_from_log_line handles partial lines and only
770+
executes once a complete line (with '\n') is received.
771+
"""
772+
from esp_idf_monitor.base.monitor_secure_exec import SecureMonitorCommandExecutor
773+
774+
calls = []
775+
776+
def fake_check_output(argv, stderr=None, env=None, shell=None):
777+
calls.append(
778+
{
779+
'argv': argv,
780+
'stderr': stderr,
781+
'env': env,
782+
'shell': shell,
783+
}
784+
)
785+
return b'OK\n'
786+
787+
monkeypatch.setattr(
788+
'esp_idf_monitor.base.monitor_secure_exec.subprocess.check_output',
789+
fake_check_output,
790+
)
791+
792+
logger = self.DummyLogger()
793+
executor = SecureMonitorCommandExecutor(logger)
794+
795+
# First chunk: no newline yet, should not trigger execution
796+
chunk1 = b'I (311) example: IDF_MONITOR_EXECUTE_ESPEFUSE_SUMMARY EFSR:esp32c3:100:AAA'
797+
executor.execute_from_log_line(chunk1)
798+
assert calls == []
799+
assert logger.outputs == []
800+
801+
# Second chunk: completes the line with '\n'
802+
chunk2 = b'BBB\n'
803+
executor.execute_from_log_line(chunk2)
804+
805+
# Now we expect exactly one call, with the combined token "AAABBB"
806+
assert len(calls) == 1
807+
call = calls[0]
808+
argv = call['argv']
809+
assert argv == ['espefuse', '--token', 'EFSR:esp32c3:100:AAABBB', 'summary', '--active']
810+
assert call['shell'] is False
811+
assert logger.outputs == [b'OK\n']

0 commit comments

Comments
 (0)