Skip to content

Commit 1b7eafe

Browse files
committed
fixed code rabbit suggested issues
1 parent 47b7aad commit 1b7eafe

6 files changed

Lines changed: 75 additions & 24 deletions

File tree

nodes/src/nodes/tool_shell/IGlobal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def beginGlobal(self) -> None:
7070
self.timeout = parse_timeout(cfg)
7171
self.max_output_bytes = parse_max_output(cfg)
7272
self.env_vars = parse_env_vars(cfg)
73-
self.allow_external_env = bool(cfg.get('allowExternalEnv', True))
73+
self.allow_external_env = bool(cfg.get('allowExternalEnv', False))
7474

7575
invalid_pattern_errors: list[str] = []
7676

nodes/src/nodes/tool_shell/IInstance.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,11 @@ def execute(self, args):
120120
)
121121

122122
def _validate_command(self, command: str) -> None:
123-
"""Reject commands that don't match any configured allowlist regex."""
123+
"""Reject commands that don't fully match any configured allowlist regex."""
124+
# Use fullmatch (not search) so that an unanchored pattern like
125+
# "git status" cannot be smuggled past via "git status; rm -rf /".
124126
patterns = self.IGlobal.command_patterns or []
125-
if patterns and not any(p.search(command) for p in patterns):
127+
if patterns and not any(p.fullmatch(command) for p in patterns):
126128
raise ValueError('Command is not permitted by the configured allowlist.')
127129

128130
def _resolve_cwd(self, override: object) -> str | None:

nodes/src/nodes/tool_shell/README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,15 @@ Common use cases:
5454

5555
## Configuration
5656

57-
| Field | Description |
58-
| ----------------------------- | -------------------------------------------------------------------------------------------- |
59-
| Tool Namespace | Prefix for the tool name (default: `shell`) |
60-
| Default working directory | Working directory used when the agent does not provide one. Defaults to the host process CWD |
61-
| Execution timeout (seconds) | Maximum seconds a command may run (default 30, max 1800) |
62-
| Max output size (bytes) | Cap on stdout and stderr each (default 1 MiB). Output beyond this is truncated |
63-
| Allow agent-supplied env vars | Whether the agent may add env vars per call. Node-defined vars always take precedence |
64-
| Environment variables | Variables injected into every command |
65-
| Command allowlist | Regex patterns. If non-empty, the command must match at least one pattern to run |
57+
| Field | Description |
58+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
59+
| Tool Namespace | Prefix for the tool name (default: `shell`) |
60+
| Default working directory | Working directory used when the agent does not provide one. Defaults to the host process CWD |
61+
| Execution timeout (seconds) | Maximum seconds a command may run (default 30, max 1800) |
62+
| Max output size (bytes) | Cap on stdout and stderr each (default 1 MiB). Output beyond this is truncated |
63+
| Allow agent-supplied env vars | Whether the agent may add env vars per call (default off). Node-defined vars always take precedence when on |
64+
| Environment variables | Variables injected into every command |
65+
| Command allowlist | Regex patterns. If non-empty, the full command must match at least one pattern (re.fullmatch). Use `.*` for substring matches, e.g. `npm .*` |
6666

6767
## Security
6868

nodes/src/nodes/tool_shell/services.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@
5656
"tool_shell.allowExternalEnv": {
5757
"type": "boolean",
5858
"title": "Allow agent-supplied env vars",
59-
"description": "If enabled, the agent may inject additional environment variables per call. Variables defined in this node's config always take precedence.",
60-
"default": true,
59+
"description": "If enabled, the agent may inject additional environment variables per call. Variables defined in this node's config always take precedence. Defaults off because env vars like LD_PRELOAD, PATH, or NODE_OPTIONS can redirect command execution.",
60+
"default": false,
6161
"enum": [
6262
[true, "Yes"],
6363
[false, "No"]
@@ -91,7 +91,7 @@
9191
},
9292
"tool_shell.commandAllowlist": {
9393
"title": "Command allowlist",
94-
"description": "Regex patterns for allowed commands. A command must match at least one pattern to run. If empty, any command is allowed.",
94+
"description": "Regex patterns for allowed commands. The full command string must match at least one pattern (re.fullmatch) to run; use .* for substring matching (e.g. 'npm .*'). If empty, any command is allowed.",
9595
"type": "array",
9696
"optional": true,
9797
"minItems": 0,

nodes/src/nodes/tool_shell/shell_executor.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,43 @@
3030
from __future__ import annotations
3131

3232
import os
33+
import signal
3334
import subprocess
35+
import sys
3436
import threading
3537

3638

3739
_TRUNCATED_MARKER = '\n...[truncated]'
3840
_READ_CHUNK_SIZE = 4096
41+
_IS_WINDOWS = sys.platform == 'win32'
42+
43+
44+
def _kill_process_tree(proc: subprocess.Popen) -> None:
45+
"""Force-kill *proc* and every descendant it spawned through the shell."""
46+
if _IS_WINDOWS:
47+
# taskkill /T walks the process tree; /F forces termination.
48+
# Falls back to proc.kill() if taskkill itself can't run.
49+
try:
50+
subprocess.run(
51+
['taskkill', '/T', '/F', '/PID', str(proc.pid)],
52+
stdout=subprocess.DEVNULL,
53+
stderr=subprocess.DEVNULL,
54+
timeout=5,
55+
check=False,
56+
)
57+
except (OSError, subprocess.SubprocessError):
58+
try:
59+
proc.kill()
60+
except OSError:
61+
pass
62+
else:
63+
try:
64+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
65+
except (ProcessLookupError, PermissionError, OSError):
66+
try:
67+
proc.kill()
68+
except OSError:
69+
pass
3970

4071

4172
def execute_command(
@@ -54,15 +85,23 @@ def execute_command(
5485
are drained and discarded so the child can finish writing without
5586
blocking on a full OS pipe (preserving its natural exit code).
5687
"""
88+
# Spawn the child in its own process group/session so we can later kill
89+
# the entire tree on timeout — otherwise shell-spawned grandchildren
90+
# outlive proc.kill() and keep our reader threads blocked on their pipes.
91+
popen_kwargs: dict = {
92+
'shell': True,
93+
'cwd': cwd,
94+
'env': env,
95+
'stdout': subprocess.PIPE,
96+
'stderr': subprocess.PIPE,
97+
}
98+
if _IS_WINDOWS:
99+
popen_kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP
100+
else:
101+
popen_kwargs['start_new_session'] = True
102+
57103
try:
58-
proc = subprocess.Popen(
59-
command,
60-
shell=True,
61-
cwd=cwd,
62-
env=env,
63-
stdout=subprocess.PIPE,
64-
stderr=subprocess.PIPE,
65-
)
104+
proc = subprocess.Popen(command, **popen_kwargs)
66105
except FileNotFoundError as exc:
67106
return {
68107
'stdout': '',
@@ -105,7 +144,7 @@ def _drain(stream, buf: bytearray, key: str) -> None:
105144
exit_code = proc.wait(timeout=timeout)
106145
except subprocess.TimeoutExpired:
107146
timed_out = True
108-
proc.kill()
147+
_kill_process_tree(proc)
109148
proc.wait()
110149
exit_code = -1
111150

nodes/test/tool_shell/test_shell_executor.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,25 @@ class TestExecuteCommandTimeout:
7171

7272
def test_kills_long_running_command(self):
7373
"""Long-running commands are killed and reported as timed_out."""
74+
import time
75+
76+
# Track wall time so we can verify the tree-kill returns promptly.
77+
start = time.monotonic()
7478
result = execute_command(
7579
f'{PY} -c "import time; time.sleep(5)"',
7680
cwd=None,
7781
env=dict(os.environ),
7882
timeout=1,
7983
max_output_bytes=4096,
8084
)
85+
elapsed = time.monotonic() - start
8186
assert result['timed_out'] is True
8287
assert result['exit_code'] == -1
88+
# Without tree-kill the reader threads stay blocked on the orphan's
89+
# pipes until the child's own sleep finishes (~5s). Tree-kill should
90+
# bring everything down well under that. Generous bound to absorb
91+
# CI jitter while still failing if the regression returns.
92+
assert elapsed < 4.0, f'expected tree-kill to return fast; took {elapsed:.2f}s'
8393

8494

8595
class TestExecuteCommandWorkingDir:

0 commit comments

Comments
 (0)