cyber-shell-wrapper 0.1.0__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.
- cyber_shell_wrapper-0.1.0/PKG-INFO +196 -0
- cyber_shell_wrapper-0.1.0/README.md +179 -0
- cyber_shell_wrapper-0.1.0/pyproject.toml +33 -0
- cyber_shell_wrapper-0.1.0/setup.cfg +4 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/__init__.py +5 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/assembler.py +174 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/cli.py +125 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/config.py +219 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/logging_utils.py +25 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/mock_endpoint.py +218 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/models.py +23 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/rcfile.py +147 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/shell_wrapper.py +292 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell/telemetry.py +121 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell_wrapper.egg-info/PKG-INFO +196 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell_wrapper.egg-info/SOURCES.txt +19 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell_wrapper.egg-info/dependency_links.txt +1 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell_wrapper.egg-info/entry_points.txt +2 -0
- cyber_shell_wrapper-0.1.0/src/cyber_shell_wrapper.egg-info/top_level.txt +1 -0
- cyber_shell_wrapper-0.1.0/tests/test_assembler.py +64 -0
- cyber_shell_wrapper-0.1.0/tests/test_config.py +92 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cyber-shell-wrapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local PTY shell wrapper for cyber range AI telemetry
|
|
5
|
+
Author: Nguyen Nhan M.M
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Topic :: System :: Shells
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Cyber Shell
|
|
19
|
+
|
|
20
|
+
`cyber-shell` is a local CLI wrapper for interactive Bash on Linux/Kali. It runs a real shell inside a PTY, preserves normal terminal behavior, captures command/output/exit code/cwd, and sends telemetry to a configurable HTTP endpoint in fail-open mode.
|
|
21
|
+
|
|
22
|
+
`endpoint_url` is the URL of the receiving server. For local testing, that can be the same machine running the mock endpoint. For real deployment, it should point to your backend or AI ingestion server, not to the student machine unless that machine is intentionally hosting the receiver.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- Runs a real interactive Bash session inside a PTY.
|
|
27
|
+
- Relays stdin/stdout in raw terminal mode with resize support.
|
|
28
|
+
- Uses shell hooks over a dedicated control pipe instead of printing markers into the terminal.
|
|
29
|
+
- Captures one logical event per command with:
|
|
30
|
+
- `cmd`
|
|
31
|
+
- `output`
|
|
32
|
+
- `exit_code`
|
|
33
|
+
- `cwd`
|
|
34
|
+
- `started_at`
|
|
35
|
+
- `finished_at`
|
|
36
|
+
- `is_interactive`
|
|
37
|
+
- Sends telemetry asynchronously with timeout, retry, and fail-open behavior.
|
|
38
|
+
- Truncates oversized command output with `max_output_bytes`.
|
|
39
|
+
- Includes a built-in mock endpoint and dashboard for local testing.
|
|
40
|
+
- Strips ANSI color/control sequences from telemetry output while keeping the local terminal unchanged.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
Assume Python 3 and `pip` are already installed and working normally.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python3 -m pip install cyber-shell-wrapper
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
The fastest test flow uses two terminals.
|
|
53
|
+
|
|
54
|
+
Terminal 1: start the mock endpoint
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cyber-shell mock-endpoint --host 0.0.0.0 --port 8080 --api-key replace-me
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Open the dashboard locally:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
http://127.0.0.1:8080/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
If you are accessing it from another machine:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
http://<server-ip>:8080/
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Check that the endpoint is alive:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
curl -i http://127.0.0.1:8080/health
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Terminal 2: start the wrapped shell and point it at the mock endpoint
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cyber-shell --endpoint-url http://127.0.0.1:8080/api/terminal-events --api-key replace-me
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Alternative: export the variables first, then start `cyber-shell`
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
export CYBER_SHELL_ENDPOINT_URL=http://127.0.0.1:8080/api/terminal-events
|
|
88
|
+
export CYBER_SHELL_API_KEY=replace-me
|
|
89
|
+
cyber-shell
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Inside that wrapped shell, run a few commands:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
whoami
|
|
96
|
+
pwd
|
|
97
|
+
ls -la
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
To confirm that you are inside the wrapped session:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
echo $CYBER_SHELL_SESSION_ID
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If it prints a value like `sess-...`, you are inside a `cyber-shell` session.
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
By default, `cyber-shell` reads:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
~/.config/cyber-shell/config.yaml
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The config format is intentionally simple YAML with optional environment variable overrides.
|
|
117
|
+
|
|
118
|
+
Runtime precedence is:
|
|
119
|
+
|
|
120
|
+
- CLI arguments
|
|
121
|
+
- environment variables
|
|
122
|
+
- config file
|
|
123
|
+
- built-in defaults
|
|
124
|
+
|
|
125
|
+
When you start the wrapped shell with runtime overrides such as `--endpoint-url`, `--api-key`, or exported `CYBER_SHELL_*` variables, `cyber-shell` writes the effective values back into `~/.config/cyber-shell/config.yaml`. In this project, that file acts as a temporary session cache so later terminals can reuse the same lab settings without retyping them.
|
|
126
|
+
|
|
127
|
+
Sample config:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
endpoint_url: "http://127.0.0.1:8080/api/terminal-events"
|
|
131
|
+
api_key: "replace-me"
|
|
132
|
+
timeout_ms: 3000
|
|
133
|
+
retry_max: 3
|
|
134
|
+
retry_backoff_ms: 1000
|
|
135
|
+
max_output_bytes: 262144
|
|
136
|
+
queue_size: 256
|
|
137
|
+
shell_path: "/bin/bash"
|
|
138
|
+
metadata:
|
|
139
|
+
hostname_group: "kali-lab"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Print the default template:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
cyber-shell print-default-config
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Supported environment overrides:
|
|
149
|
+
|
|
150
|
+
- `CYBER_SHELL_CONFIG`
|
|
151
|
+
- `CYBER_SHELL_ENDPOINT_URL`
|
|
152
|
+
- `CYBER_SHELL_API_KEY`
|
|
153
|
+
- `CYBER_SHELL_TIMEOUT_MS`
|
|
154
|
+
- `CYBER_SHELL_RETRY_MAX`
|
|
155
|
+
- `CYBER_SHELL_RETRY_BACKOFF_MS`
|
|
156
|
+
- `CYBER_SHELL_MAX_OUTPUT_BYTES`
|
|
157
|
+
- `CYBER_SHELL_QUEUE_SIZE`
|
|
158
|
+
- `CYBER_SHELL_SHELL_PATH`
|
|
159
|
+
|
|
160
|
+
## Telemetry Flow
|
|
161
|
+
|
|
162
|
+
The wrapper sends telemetry with `POST` requests to `endpoint_url`.
|
|
163
|
+
|
|
164
|
+
- Local test flow:
|
|
165
|
+
- `cyber-shell` posts JSON to `http://127.0.0.1:8080/api/terminal-events`
|
|
166
|
+
- the mock endpoint displays those events in its dashboard
|
|
167
|
+
- Production flow:
|
|
168
|
+
- `cyber-shell` posts JSON to your backend or AI ingestion server
|
|
169
|
+
- that server stores the events, forwards them, or exposes them to downstream AI components
|
|
170
|
+
|
|
171
|
+
The wrapper does not need to `GET` logs back from the AI server for the core design. The primary contract is outbound `POST` from the PTY wrapper to the server. Optional `GET` endpoints such as `GET /events` are only for debugging, review, or dashboards.
|
|
172
|
+
|
|
173
|
+
## Manual Endpoint Test
|
|
174
|
+
|
|
175
|
+
You can test the dashboard without starting the wrapped shell:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
curl -i -X POST http://127.0.0.1:8080/api/terminal-events \
|
|
179
|
+
-H "Content-Type: application/json" \
|
|
180
|
+
-H "Authorization: Bearer replace-me" \
|
|
181
|
+
-d '{"session_id":"s1","seq":1,"cmd":"whoami","cwd":"/home/kali","exit_code":0,"output":"kali","output_truncated":false,"started_at":"2026-03-21T10:00:00Z","finished_at":"2026-03-21T10:00:01Z","is_interactive":false,"hostname":"kali","shell":"bash","metadata":{}}'
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Notes
|
|
185
|
+
|
|
186
|
+
- This tool targets POSIX/Linux, with Kali as the primary environment.
|
|
187
|
+
- V1 does not semantically parse `vim`, `top`, `nano`, `less`, or `man`; it only preserves terminal behavior and finalizes the event when the prompt returns.
|
|
188
|
+
- Nested shells and remote shells are treated as opaque terminal streams.
|
|
189
|
+
- The wrapper does not capture raw keystrokes for the entire session.
|
|
190
|
+
|
|
191
|
+
## Dev Checks
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
python -m unittest discover -s tests -v
|
|
195
|
+
python -m compileall src tests
|
|
196
|
+
```
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Cyber Shell
|
|
2
|
+
|
|
3
|
+
`cyber-shell` is a local CLI wrapper for interactive Bash on Linux/Kali. It runs a real shell inside a PTY, preserves normal terminal behavior, captures command/output/exit code/cwd, and sends telemetry to a configurable HTTP endpoint in fail-open mode.
|
|
4
|
+
|
|
5
|
+
`endpoint_url` is the URL of the receiving server. For local testing, that can be the same machine running the mock endpoint. For real deployment, it should point to your backend or AI ingestion server, not to the student machine unless that machine is intentionally hosting the receiver.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Runs a real interactive Bash session inside a PTY.
|
|
10
|
+
- Relays stdin/stdout in raw terminal mode with resize support.
|
|
11
|
+
- Uses shell hooks over a dedicated control pipe instead of printing markers into the terminal.
|
|
12
|
+
- Captures one logical event per command with:
|
|
13
|
+
- `cmd`
|
|
14
|
+
- `output`
|
|
15
|
+
- `exit_code`
|
|
16
|
+
- `cwd`
|
|
17
|
+
- `started_at`
|
|
18
|
+
- `finished_at`
|
|
19
|
+
- `is_interactive`
|
|
20
|
+
- Sends telemetry asynchronously with timeout, retry, and fail-open behavior.
|
|
21
|
+
- Truncates oversized command output with `max_output_bytes`.
|
|
22
|
+
- Includes a built-in mock endpoint and dashboard for local testing.
|
|
23
|
+
- Strips ANSI color/control sequences from telemetry output while keeping the local terminal unchanged.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Assume Python 3 and `pip` are already installed and working normally.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python3 -m pip install cyber-shell-wrapper
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
The fastest test flow uses two terminals.
|
|
36
|
+
|
|
37
|
+
Terminal 1: start the mock endpoint
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cyber-shell mock-endpoint --host 0.0.0.0 --port 8080 --api-key replace-me
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Open the dashboard locally:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
http://127.0.0.1:8080/
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you are accessing it from another machine:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
http://<server-ip>:8080/
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Check that the endpoint is alive:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
curl -i http://127.0.0.1:8080/health
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Terminal 2: start the wrapped shell and point it at the mock endpoint
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
cyber-shell --endpoint-url http://127.0.0.1:8080/api/terminal-events --api-key replace-me
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Alternative: export the variables first, then start `cyber-shell`
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
export CYBER_SHELL_ENDPOINT_URL=http://127.0.0.1:8080/api/terminal-events
|
|
71
|
+
export CYBER_SHELL_API_KEY=replace-me
|
|
72
|
+
cyber-shell
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Inside that wrapped shell, run a few commands:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
whoami
|
|
79
|
+
pwd
|
|
80
|
+
ls -la
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
To confirm that you are inside the wrapped session:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
echo $CYBER_SHELL_SESSION_ID
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
If it prints a value like `sess-...`, you are inside a `cyber-shell` session.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
By default, `cyber-shell` reads:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
~/.config/cyber-shell/config.yaml
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The config format is intentionally simple YAML with optional environment variable overrides.
|
|
100
|
+
|
|
101
|
+
Runtime precedence is:
|
|
102
|
+
|
|
103
|
+
- CLI arguments
|
|
104
|
+
- environment variables
|
|
105
|
+
- config file
|
|
106
|
+
- built-in defaults
|
|
107
|
+
|
|
108
|
+
When you start the wrapped shell with runtime overrides such as `--endpoint-url`, `--api-key`, or exported `CYBER_SHELL_*` variables, `cyber-shell` writes the effective values back into `~/.config/cyber-shell/config.yaml`. In this project, that file acts as a temporary session cache so later terminals can reuse the same lab settings without retyping them.
|
|
109
|
+
|
|
110
|
+
Sample config:
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
endpoint_url: "http://127.0.0.1:8080/api/terminal-events"
|
|
114
|
+
api_key: "replace-me"
|
|
115
|
+
timeout_ms: 3000
|
|
116
|
+
retry_max: 3
|
|
117
|
+
retry_backoff_ms: 1000
|
|
118
|
+
max_output_bytes: 262144
|
|
119
|
+
queue_size: 256
|
|
120
|
+
shell_path: "/bin/bash"
|
|
121
|
+
metadata:
|
|
122
|
+
hostname_group: "kali-lab"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Print the default template:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
cyber-shell print-default-config
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Supported environment overrides:
|
|
132
|
+
|
|
133
|
+
- `CYBER_SHELL_CONFIG`
|
|
134
|
+
- `CYBER_SHELL_ENDPOINT_URL`
|
|
135
|
+
- `CYBER_SHELL_API_KEY`
|
|
136
|
+
- `CYBER_SHELL_TIMEOUT_MS`
|
|
137
|
+
- `CYBER_SHELL_RETRY_MAX`
|
|
138
|
+
- `CYBER_SHELL_RETRY_BACKOFF_MS`
|
|
139
|
+
- `CYBER_SHELL_MAX_OUTPUT_BYTES`
|
|
140
|
+
- `CYBER_SHELL_QUEUE_SIZE`
|
|
141
|
+
- `CYBER_SHELL_SHELL_PATH`
|
|
142
|
+
|
|
143
|
+
## Telemetry Flow
|
|
144
|
+
|
|
145
|
+
The wrapper sends telemetry with `POST` requests to `endpoint_url`.
|
|
146
|
+
|
|
147
|
+
- Local test flow:
|
|
148
|
+
- `cyber-shell` posts JSON to `http://127.0.0.1:8080/api/terminal-events`
|
|
149
|
+
- the mock endpoint displays those events in its dashboard
|
|
150
|
+
- Production flow:
|
|
151
|
+
- `cyber-shell` posts JSON to your backend or AI ingestion server
|
|
152
|
+
- that server stores the events, forwards them, or exposes them to downstream AI components
|
|
153
|
+
|
|
154
|
+
The wrapper does not need to `GET` logs back from the AI server for the core design. The primary contract is outbound `POST` from the PTY wrapper to the server. Optional `GET` endpoints such as `GET /events` are only for debugging, review, or dashboards.
|
|
155
|
+
|
|
156
|
+
## Manual Endpoint Test
|
|
157
|
+
|
|
158
|
+
You can test the dashboard without starting the wrapped shell:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
curl -i -X POST http://127.0.0.1:8080/api/terminal-events \
|
|
162
|
+
-H "Content-Type: application/json" \
|
|
163
|
+
-H "Authorization: Bearer replace-me" \
|
|
164
|
+
-d '{"session_id":"s1","seq":1,"cmd":"whoami","cwd":"/home/kali","exit_code":0,"output":"kali","output_truncated":false,"started_at":"2026-03-21T10:00:00Z","finished_at":"2026-03-21T10:00:01Z","is_interactive":false,"hostname":"kali","shell":"bash","metadata":{}}'
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Notes
|
|
168
|
+
|
|
169
|
+
- This tool targets POSIX/Linux, with Kali as the primary environment.
|
|
170
|
+
- V1 does not semantically parse `vim`, `top`, `nano`, `less`, or `man`; it only preserves terminal behavior and finalizes the event when the prompt returns.
|
|
171
|
+
- Nested shells and remote shells are treated as opaque terminal streams.
|
|
172
|
+
- The wrapper does not capture raw keystrokes for the entire session.
|
|
173
|
+
|
|
174
|
+
## Dev Checks
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
python -m unittest discover -s tests -v
|
|
178
|
+
python -m compileall src tests
|
|
179
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cyber-shell-wrapper"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local PTY shell wrapper for cyber range AI telemetry"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Nguyen Nhan M.M" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
"Topic :: System :: Shells",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
cyber-shell = "cyber_shell.cli:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
package-dir = { "" = "src" }
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .config import AppConfig
|
|
8
|
+
from .models import ShellEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
INTERACTIVE_COMMANDS = {
|
|
12
|
+
"alsamixer",
|
|
13
|
+
"ftp",
|
|
14
|
+
"htop",
|
|
15
|
+
"less",
|
|
16
|
+
"man",
|
|
17
|
+
"more",
|
|
18
|
+
"mongo",
|
|
19
|
+
"mysql",
|
|
20
|
+
"nano",
|
|
21
|
+
"nmtui",
|
|
22
|
+
"psql",
|
|
23
|
+
"python",
|
|
24
|
+
"python3",
|
|
25
|
+
"redis-cli",
|
|
26
|
+
"sftp",
|
|
27
|
+
"ssh",
|
|
28
|
+
"sqlite3",
|
|
29
|
+
"telnet",
|
|
30
|
+
"tig",
|
|
31
|
+
"tmux",
|
|
32
|
+
"top",
|
|
33
|
+
"vi",
|
|
34
|
+
"view",
|
|
35
|
+
"vim",
|
|
36
|
+
"watch",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
PREFIX_WRAPPERS = {
|
|
40
|
+
"builtin",
|
|
41
|
+
"chronic",
|
|
42
|
+
"command",
|
|
43
|
+
"env",
|
|
44
|
+
"exec",
|
|
45
|
+
"nohup",
|
|
46
|
+
"stdbuf",
|
|
47
|
+
"time",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
ANSI_ESCAPE_RE = re.compile(
|
|
51
|
+
r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x1b\x07]*(?:\x07|\x1b\\))"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class ActiveCommand:
|
|
57
|
+
started_at: str
|
|
58
|
+
cmd: str
|
|
59
|
+
output_buffer: bytearray
|
|
60
|
+
truncated: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EventAssembler:
|
|
64
|
+
def __init__(self, config: AppConfig, session_id: str) -> None:
|
|
65
|
+
self._config = config
|
|
66
|
+
self._session_id = session_id
|
|
67
|
+
self._seq = 0
|
|
68
|
+
self._current: ActiveCommand | None = None
|
|
69
|
+
|
|
70
|
+
def start_command(self, started_at: str, cmd: str) -> None:
|
|
71
|
+
self._current = ActiveCommand(
|
|
72
|
+
started_at=started_at,
|
|
73
|
+
cmd=cmd.strip(),
|
|
74
|
+
output_buffer=bytearray(),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def append_output(self, chunk: bytes) -> None:
|
|
78
|
+
if self._current is None or not chunk:
|
|
79
|
+
return
|
|
80
|
+
remaining = self._config.max_output_bytes - len(self._current.output_buffer)
|
|
81
|
+
if remaining <= 0:
|
|
82
|
+
self._current.truncated = True
|
|
83
|
+
return
|
|
84
|
+
self._current.output_buffer.extend(chunk[:remaining])
|
|
85
|
+
if len(chunk) > remaining:
|
|
86
|
+
self._current.truncated = True
|
|
87
|
+
|
|
88
|
+
def finish_command(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
finished_at: str,
|
|
92
|
+
exit_code: int,
|
|
93
|
+
cwd: str,
|
|
94
|
+
) -> ShellEvent | None:
|
|
95
|
+
current = self._current
|
|
96
|
+
self._current = None
|
|
97
|
+
if current is None or not current.cmd:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
self._seq += 1
|
|
101
|
+
return ShellEvent(
|
|
102
|
+
session_id=self._session_id,
|
|
103
|
+
hostname=self._config.hostname,
|
|
104
|
+
shell="bash",
|
|
105
|
+
seq=self._seq,
|
|
106
|
+
cwd=cwd,
|
|
107
|
+
cmd=current.cmd,
|
|
108
|
+
exit_code=exit_code,
|
|
109
|
+
output=_sanitize_output(
|
|
110
|
+
current.output_buffer.decode("utf-8", errors="replace")
|
|
111
|
+
),
|
|
112
|
+
output_truncated=current.truncated,
|
|
113
|
+
started_at=current.started_at,
|
|
114
|
+
finished_at=finished_at,
|
|
115
|
+
is_interactive=is_interactive_command(current.cmd),
|
|
116
|
+
metadata=dict(self._config.metadata),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_interactive_command(command: str) -> bool:
|
|
121
|
+
executable = _extract_command_name(command)
|
|
122
|
+
if executable is None:
|
|
123
|
+
return False
|
|
124
|
+
return executable in INTERACTIVE_COMMANDS
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_command_name(command: str) -> str | None:
|
|
128
|
+
try:
|
|
129
|
+
tokens = shlex.split(command, posix=True)
|
|
130
|
+
except ValueError:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
index = 0
|
|
134
|
+
while index < len(tokens):
|
|
135
|
+
token = tokens[index]
|
|
136
|
+
if _looks_like_env_assignment(token):
|
|
137
|
+
index += 1
|
|
138
|
+
continue
|
|
139
|
+
if token == "sudo":
|
|
140
|
+
index += 1
|
|
141
|
+
while index < len(tokens):
|
|
142
|
+
sudo_token = tokens[index]
|
|
143
|
+
if sudo_token == "--":
|
|
144
|
+
index += 1
|
|
145
|
+
break
|
|
146
|
+
if not sudo_token.startswith("-"):
|
|
147
|
+
break
|
|
148
|
+
index += 1
|
|
149
|
+
if sudo_token in {"-g", "-h", "-p", "-u"} and index < len(tokens):
|
|
150
|
+
index += 1
|
|
151
|
+
continue
|
|
152
|
+
if token == "env":
|
|
153
|
+
index += 1
|
|
154
|
+
while index < len(tokens) and _looks_like_env_assignment(tokens[index]):
|
|
155
|
+
index += 1
|
|
156
|
+
continue
|
|
157
|
+
if token in PREFIX_WRAPPERS:
|
|
158
|
+
index += 1
|
|
159
|
+
continue
|
|
160
|
+
return token
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _looks_like_env_assignment(token: str) -> bool:
|
|
165
|
+
if "=" not in token or token.startswith("="):
|
|
166
|
+
return False
|
|
167
|
+
name, _ = token.split("=", 1)
|
|
168
|
+
return name.replace("_", "A").isalnum()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sanitize_output(value: str) -> str:
|
|
172
|
+
cleaned = ANSI_ESCAPE_RE.sub("", value)
|
|
173
|
+
cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
|
|
174
|
+
return cleaned
|