Skip to content

Commit ef0fbfc

Browse files
author
Mateusz
committed
feat: Add inline python steering handler and enhance dangerous command protection for project root integrity
1 parent 29c2b45 commit ef0fbfc

File tree

14 files changed

+779
-70
lines changed

14 files changed

+779
-70
lines changed

config/schemas/tool_call_reactor_config.schema.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ properties:
2020
type:
2121
- string
2222
- "null"
23+
inline_python_steering_enabled:
24+
type: boolean
25+
inline_python_steering_message:
26+
type:
27+
- string
28+
- "null"
2329
test_execution_reminder_enabled:
2430
type: boolean
2531
test_execution_reminder_message:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"count": 66,
3+
"last_reset_date": "2025-12-07",
4+
"logged_thresholds": []
5+
}

docs/user_guide/features/dangerous-command-protection.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,5 +126,6 @@ Only disable this protection if you:
126126
## Related Features
127127

128128
- [Tool Access Control](tool-access-control.md) - Fine-grained control over tool execution
129-
- [File Access Sandboxing](file-access-sandboxing.md) - Restrict file operations to project directory
129+
- [File Access Sandboxing](file-sandboxing.md) - Restrict file operations to project directory
130+
- [Inline Python Steering](inline-python-steering.md) - Prevent unstable inline Python execution
130131
- [Angel Verification System](angel-verification.md) - Real-time response verification

docs/user_guide/features/file-sandboxing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,8 @@ Priority level: **80** (runs before most handlers, after authentication)
353353
## Related Documentation
354354

355355
- [Tool Call Reactor System](./tool-call-reactor.md)
356+
- [Dangerous Command Protection](dangerous-command-protection.md)
357+
- [Inline Python Steering](inline-python-steering.md)
356358
- [Security Best Practices](../security/best-practices.md)
357359
- [Configuration Guide](../configuration.md)
358360
- [CLI Parameters](../cli-parameters.md)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Inline Python Steering
2+
3+
Prevent unstable inline Python execution by steering agents toward script-based execution.
4+
5+
## Overview
6+
7+
The Inline Python Steering feature intercepts attempts to run inline Python code via shell commands (e.g., `python -c "..."`) and guides the agent to use temporary scripts instead. Inline Python code execution in terminals is often unstable, prone to quoting issues, and difficult to debug. By enforcing script usage, this feature improves reliability and maintainability of agent-generated code execution.
8+
9+
When an agent attempts to execute inline Python, the proxy blocks the call and returns a steering message explaining the issue and suggesting the creation of a temporary script.
10+
11+
## Key Features
12+
13+
- **Automatic Detection**: Recognizes various inline Python patterns (`python -c`, `python3 -c`, etc.)
14+
- **Immediate Blocking**: Prevents the command from executing on the host
15+
- **Helpful Steering**: Explains why the command was blocked and what to do instead
16+
- **Robust Parsing**: Handles various python executables and flags
17+
- **Tool Awareness**: Monitors known shell execution tools
18+
19+
## Configuration
20+
21+
The feature is disabled by default and can be enabled via environment variable or YAML configuration.
22+
23+
### Environment Variable
24+
25+
```bash
26+
export INLINE_PYTHON_STEERING_ENABLED=true
27+
```
28+
29+
### YAML Configuration
30+
31+
```yaml
32+
session:
33+
inline_python_steering_enabled: true # Default: false
34+
35+
# Optional: Custom steering message
36+
inline_python_steering_message: |
37+
Inline Python execution is blocked for stability reasons.
38+
Please write your code to a temporary file (e.g., script.py) and execute that file instead.
39+
```
40+
41+
## Usage Examples
42+
43+
### Enable with Default Message
44+
45+
Set the environment variable before running the proxy:
46+
47+
```bash
48+
export INLINE_PYTHON_STEERING_ENABLED=true
49+
.venv/Scripts/python.exe -m src.core.cli --default-backend openai
50+
```
51+
52+
### Enable with Custom Message
53+
54+
Create `config/my_config.yaml`:
55+
56+
```yaml
57+
session:
58+
inline_python_steering_enabled: true
59+
inline_python_steering_message: |
60+
[Security/Stability Notice]
61+
You are attempting to run inline Python code (python -c).
62+
This pattern is unreliable in this environment.
63+
64+
Please:
65+
1. Create a file named 'temp_script.py' with your code
66+
2. Run 'python temp_script.py'
67+
```
68+
69+
Then run:
70+
71+
```bash
72+
.venv/Scripts/python.exe -m src.core.cli --config config/my_config.yaml
73+
```
74+
75+
## Detection Logic
76+
77+
The handler detects commands matching the following patterns:
78+
79+
- `python -c "..."`
80+
- `python3 -c '...'`
81+
- `python.exe -c "..."`
82+
- `python -u -c "..."` (with flags)
83+
84+
It specifically looks for the `-c` flag combined with a Python executable. Normal Python file execution (e.g., `python script.py` or `python -m pytest`) is **allowed**.
85+
86+
## Recognized Shell Tools
87+
88+
The handler monitors the following shell execution tools (from `ShellExecutionTools` constant):
89+
90+
- `bash`
91+
- `Execute`
92+
- `ShellTool`
93+
- `exec_command`
94+
- `execute_command`
95+
- `run_shell_command`
96+
- `run_terminal_command`
97+
- `shell`
98+
- `local_shell`
99+
- `container.exec`
100+
101+
## Rationale
102+
103+
Why block inline Python?
104+
105+
1. **Quoting Hell**: Passing complex Python code inside shell strings often leads to escaping issues, especially on Windows (cmd.exe vs PowerShell) and with nested quotes.
106+
2. **Terminal Stability**: Long or complex inline one-liners can behave unpredictably depending on the underlying shell.
107+
3. **Debuggability**: Code in a file is easier to review, debug, and log than a ephemeral one-liner.
108+
4. **Agent Behavior**: Encouraging agents to write scripts fosters better coding habits and more robust solutions.
109+
110+
## Behavior Flow
111+
112+
1. **Agent Action**: Agent calls a shell tool with `python -c "print('hello')"`
113+
2. **Interception**: The `InlinePythonSteeringHandler` detects the pattern.
114+
3. **Blocking**: The tool call is swallow (not executed).
115+
4. **Response**: The agent receives the steering message (default or custom).
116+
5. **Correction**: The agent should then create a file and execute it, which is allowed.
117+
118+
## Troubleshooting
119+
120+
**Feature not working:**
121+
- Verify `INLINE_PYTHON_STEERING_ENABLED` is set to `true`.
122+
- Ensure the tool being used is one of the recognized shell tools.
123+
124+
**False Positives:**
125+
- The regex is designed to be specific to `-c`. If you find valid commands being blocked, please report them.
126+
- Normal `python filename.py` should never be blocked.
127+
128+
## Implementation References
129+
130+
- **Handler**: `src/core/services/tool_call_handlers/inline_python_steering_handler.py`
131+
- **Tests**: `tests/unit/core/services/tool_call_handlers/test_inline_python_steering_handler.py`
132+
- **Configuration**: `src/core/config/app_config.py`
133+
- **DI Registration**: `src/core/di/services.py`

scripts/analyze_stream_field.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""
2+
Analyze CBOR wire capture to examine PROXY_TO_BACKEND request payloads in detail.
3+
"""
4+
import cbor2
5+
import json
6+
import zlib
7+
from pathlib import Path
8+
9+
# Direction constants
10+
DIRECTION_PROXY_TO_BACKEND = 2
11+
12+
13+
def main() -> None:
14+
path = Path("var/wire_captures_cbor/proxy-20251209_1017.cbor")
15+
if not path.exists():
16+
print(f"File not found: {path}")
17+
return
18+
19+
# Load using same method as inspect script
20+
entries = []
21+
with open(path, "rb") as f:
22+
header = cbor2.load(f)
23+
while True:
24+
try:
25+
entry = cbor2.load(f)
26+
# Handle decompression
27+
if entry.get("enc") == "zlib":
28+
entry["data"] = zlib.decompress(entry["data"])
29+
del entry["enc"]
30+
entries.append(entry)
31+
except (EOFError, cbor2.CBORDecodeEOF):
32+
break
33+
34+
print(f"Session ID: {header.get('session_id', 'N/A')}")
35+
print(f"Total entries: {len(entries)}")
36+
print()
37+
38+
# Look at all PROXY_TO_BACKEND entries
39+
proxy_to_backend = [e for e in entries if e.get("dir") == DIRECTION_PROXY_TO_BACKEND]
40+
print(f"Found {len(proxy_to_backend)} PROXY_TO_BACKEND entries")
41+
print("=" * 100)
42+
43+
for i, entry in enumerate(proxy_to_backend):
44+
seq = entry.get("seq", i)
45+
meta = entry.get("meta", {})
46+
data = entry.get("data", b"")
47+
backend = meta.get("be", "N/A")
48+
session_id = meta.get("sid", "N/A")[:16] if meta.get("sid") else "N/A"
49+
50+
print(f"\n[{seq}] Backend: {backend} | Session: {session_id}")
51+
print(f"Data size: {len(data)} bytes")
52+
53+
if isinstance(data, (bytes, bytearray)):
54+
try:
55+
text = data.decode("utf-8", errors="ignore")
56+
except Exception:
57+
print(" (Could not decode)")
58+
continue
59+
elif isinstance(data, str):
60+
text = data
61+
else:
62+
print(f" (Unexpected data type: {type(data)})")
63+
continue
64+
65+
# Try to parse as JSON
66+
text = text.strip()
67+
if text.startswith("{"):
68+
try:
69+
obj = json.loads(text)
70+
# Print key fields
71+
print(f" model: {obj.get('model', 'N/A')}")
72+
print(f" stream: {obj.get('stream', '(NOT PRESENT)')}")
73+
print(f" messages count: {len(obj.get('messages', []))}")
74+
75+
# Show first 200 chars of the text
76+
preview = text[:300]
77+
if len(text) > 300:
78+
preview += "..."
79+
print(f" Preview: {preview}")
80+
except json.JSONDecodeError as e:
81+
print(f" JSON parse error: {e}")
82+
print(f" First 200 chars: {text[:200]}")
83+
else:
84+
print(f" Not JSON. First 200 chars: {text[:200]}")
85+
86+
print()
87+
88+
89+
if __name__ == "__main__":
90+
main()

src/core/di/services.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2150,6 +2150,38 @@ def _tool_call_reactor_factory(
21502150
f"Failed to register PytestFullSuiteHandler: {e}", exc_info=True
21512151
)
21522152

2153+
# Register InlinePythonSteeringHandler if enabled
2154+
try:
2155+
if getattr(reactor_config, "inline_python_steering_enabled", False):
2156+
from src.core.services.tool_call_handlers.inline_python_steering_handler import (
2157+
InlinePythonSteeringHandler,
2158+
)
2159+
2160+
steering_message = getattr(
2161+
reactor_config, "inline_python_steering_message", None
2162+
)
2163+
inline_python_handler = InlinePythonSteeringHandler(
2164+
message=steering_message,
2165+
enabled=True,
2166+
)
2167+
try:
2168+
reactor.register_handler_sync(inline_python_handler)
2169+
if logger.isEnabledFor(logging.INFO):
2170+
logger.info(
2171+
"Registered InlinePythonSteeringHandler with priority 95"
2172+
)
2173+
except Exception as e:
2174+
if logger.isEnabledFor(logging.WARNING):
2175+
logger.warning(
2176+
f"Failed to register inline python steering handler: {e}",
2177+
exc_info=True,
2178+
)
2179+
except Exception as e:
2180+
if logger.isEnabledFor(logging.WARNING):
2181+
logger.warning(
2182+
f"Failed to register InlinePythonSteeringHandler: {e}", exc_info=True
2183+
)
2184+
21532185
# Register PytestContextSavingHandler if enabled
21542186
try:
21552187
if getattr(reactor_config, "pytest_context_saving_enabled", False):

src/core/domain/configuration/dangerous_command_config.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from re import Pattern
33
from typing import NamedTuple
44

5+
from src.core.domain.tool_constants import ShellExecutionTools
6+
57

68
class DangerousCommandRule(NamedTuple):
79
pattern: Pattern[str]
@@ -282,17 +284,6 @@ def get_default_dangerous_command_rules() -> list[DangerousCommandRule]:
282284
DEFAULT_DANGEROUS_COMMAND_RULES = get_default_dangerous_command_rules()
283285

284286
DEFAULT_DANGEROUS_COMMAND_CONFIG = DangerousCommandConfig(
285-
tool_names=[
286-
"bash",
287-
"Execute",
288-
"ShellTool",
289-
"exec_command",
290-
"execute_command",
291-
"run_shell_command",
292-
"run_terminal_command",
293-
"shell",
294-
"local_shell",
295-
"container.exec",
296-
],
287+
tool_names=ShellExecutionTools.get_all(),
297288
rules=DEFAULT_DANGEROUS_COMMAND_RULES,
298289
)

0 commit comments

Comments
 (0)