panopticon-cli 1.1.0__tar.gz → 1.1.2__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.
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/PKG-INFO +2 -1
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/README.md +1 -0
- panopticon_cli-1.1.2/panopticon/cli.py +267 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon_cli.egg-info/PKG-INFO +2 -1
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/pyproject.toml +5 -1
- panopticon_cli-1.1.2/tests/test_cli.py +63 -0
- panopticon_cli-1.1.0/panopticon/cli.py +0 -197
- panopticon_cli-1.1.0/tests/test_cli.py +0 -15
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/LICENSE +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon/__init__.py +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon/memory.py +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon/observer.py +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon/policies.py +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon/sentinel.py +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon_cli.egg-info/SOURCES.txt +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon_cli.egg-info/dependency_links.txt +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon_cli.egg-info/entry_points.txt +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon_cli.egg-info/requires.txt +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/panopticon_cli.egg-info/top_level.txt +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/setup.cfg +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/tests/test_memory.py +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/tests/test_observer.py +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/tests/test_policies.py +0 -0
- {panopticon_cli-1.1.0 → panopticon_cli-1.1.2}/tests/test_sentinel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: panopticon-cli
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: The Cognitive Immune System for Autonomous CLI Agents.
|
|
5
5
|
Author: Ak
|
|
6
6
|
License: MIT
|
|
@@ -34,6 +34,7 @@ Dynamic: license-file
|
|
|
34
34
|
|
|
35
35
|
[](https://pypi.org/project/panopticon-cli/)
|
|
36
36
|
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
[](https://github.com/ak495867/Panopticon/actions/workflows/ci.yml)
|
|
37
38
|
[](https://www.python.org/downloads/)
|
|
38
39
|
|
|
39
40
|
Autonomous CLI agents (like Claude Code, AutoGPT, and Antigravity) are amazing... right until they hallucinate, burn through $50 of your API credits, or confidently try to `rm -rf /` your hard drive.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://pypi.org/project/panopticon-cli/)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://github.com/ak495867/Panopticon/actions/workflows/ci.yml)
|
|
7
8
|
[](https://www.python.org/downloads/)
|
|
8
9
|
|
|
9
10
|
Autonomous CLI agents (like Claude Code, AutoGPT, and Antigravity) are amazing... right until they hallucinate, burn through $50 of your API credits, or confidently try to `rm -rf /` your hard drive.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import threading
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import codecs
|
|
10
|
+
import atexit
|
|
11
|
+
from typing import List
|
|
12
|
+
from .observer import PanopticonObserver, InterventionException
|
|
13
|
+
from .policies import AdversarialLogicCheck, AntiLoopPolicy, BlacklistPolicy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def strip_ansi(text: str) -> str:
|
|
17
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
18
|
+
return ansi_escape.sub("", text)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CLIWrapper:
|
|
22
|
+
def __init__(self, command: List[str]):
|
|
23
|
+
self.command = command
|
|
24
|
+
target_agent = self.command[0] if self.command else "unknown"
|
|
25
|
+
|
|
26
|
+
self.observer = PanopticonObserver(
|
|
27
|
+
policies=[
|
|
28
|
+
BlacklistPolicy(forbidden_patterns=["rm -rf /", "DROP TABLE"]),
|
|
29
|
+
AntiLoopPolicy(window=3, threshold=0.85),
|
|
30
|
+
AdversarialLogicCheck(target_agent=target_agent),
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
self.process = None
|
|
35
|
+
self.buffer = []
|
|
36
|
+
self.running = False
|
|
37
|
+
self.lock = threading.Lock()
|
|
38
|
+
self.threads = []
|
|
39
|
+
self.stdin_reader = None
|
|
40
|
+
|
|
41
|
+
def run(self):
|
|
42
|
+
self.running = True
|
|
43
|
+
env = os.environ.copy()
|
|
44
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
45
|
+
env["FORCE_COLOR"] = "1"
|
|
46
|
+
env["CLICOLOR_FORCE"] = "1"
|
|
47
|
+
|
|
48
|
+
print(f"[PANOPTICON] Booting Immune System for: {' '.join(self.command)}")
|
|
49
|
+
print(
|
|
50
|
+
f"[PANOPTICON] Policies Active: Blacklist, AntiLoop (Fuzzy), "
|
|
51
|
+
f"AdversarialLogic"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Flaw 2 Fix: Removing text=True to read raw binary bytes
|
|
55
|
+
self.process = subprocess.Popen(
|
|
56
|
+
self.command,
|
|
57
|
+
stdout=subprocess.PIPE,
|
|
58
|
+
stderr=subprocess.STDOUT,
|
|
59
|
+
stdin=subprocess.PIPE,
|
|
60
|
+
bufsize=0,
|
|
61
|
+
env=env,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
t_out = threading.Thread(target=self._read_stdout, daemon=True)
|
|
65
|
+
t_out.start()
|
|
66
|
+
self.threads.append(t_out)
|
|
67
|
+
|
|
68
|
+
t_eval = threading.Thread(target=self._eval_loop, daemon=True)
|
|
69
|
+
t_eval.start()
|
|
70
|
+
self.threads.append(t_eval)
|
|
71
|
+
|
|
72
|
+
t_in = threading.Thread(target=self._read_stdin, daemon=True)
|
|
73
|
+
t_in.start()
|
|
74
|
+
self.threads.append(t_in)
|
|
75
|
+
|
|
76
|
+
# Register cleanup on exit
|
|
77
|
+
atexit.register(self._cleanup)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
self.process.wait()
|
|
81
|
+
except KeyboardInterrupt:
|
|
82
|
+
pass
|
|
83
|
+
finally:
|
|
84
|
+
self.running = False
|
|
85
|
+
self._cleanup()
|
|
86
|
+
|
|
87
|
+
def _cleanup(self):
|
|
88
|
+
"""Cleanly shutdown all threads and resources."""
|
|
89
|
+
self.running = False
|
|
90
|
+
|
|
91
|
+
# Close subprocess pipes FIRST before thread cleanup
|
|
92
|
+
if self.process:
|
|
93
|
+
try:
|
|
94
|
+
if self.process.stdin and not self.process.stdin.closed:
|
|
95
|
+
self.process.stdin.close()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
if self.process.stdout and not self.process.stdout.closed:
|
|
101
|
+
self.process.stdout.close()
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
if self.process.poll() is None:
|
|
107
|
+
self.process.terminate()
|
|
108
|
+
try:
|
|
109
|
+
self.process.wait(timeout=2)
|
|
110
|
+
except subprocess.TimeoutExpired:
|
|
111
|
+
self.process.kill()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
# Wait for threads with timeout
|
|
116
|
+
for thread in self.threads:
|
|
117
|
+
if thread.is_alive():
|
|
118
|
+
thread.join(timeout=1)
|
|
119
|
+
|
|
120
|
+
def _read_stdout(self):
|
|
121
|
+
# Flaw 2 Fix: Safely decode multi-byte UTF-8 emojis incrementally
|
|
122
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
123
|
+
try:
|
|
124
|
+
while self.running and self.process and self.process.poll() is None:
|
|
125
|
+
try:
|
|
126
|
+
byte_chunk = self.process.stdout.read(1)
|
|
127
|
+
if not byte_chunk:
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
# Mirror raw bytes to actual terminal seamlessly
|
|
131
|
+
sys.stdout.buffer.write(byte_chunk)
|
|
132
|
+
sys.stdout.buffer.flush()
|
|
133
|
+
|
|
134
|
+
char_str = decoder.decode(byte_chunk)
|
|
135
|
+
if char_str:
|
|
136
|
+
with self.lock:
|
|
137
|
+
self.buffer.append(char_str)
|
|
138
|
+
except (OSError, ValueError, BrokenPipeError):
|
|
139
|
+
break
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
finally:
|
|
143
|
+
try:
|
|
144
|
+
if (
|
|
145
|
+
self.process
|
|
146
|
+
and self.process.stdout
|
|
147
|
+
and (not self.process.stdout.closed)
|
|
148
|
+
):
|
|
149
|
+
self.process.stdout.close()
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def _read_stdin(self):
|
|
154
|
+
try:
|
|
155
|
+
while self.running and self.process and self.process.poll() is None:
|
|
156
|
+
try:
|
|
157
|
+
data = sys.stdin.buffer.read1(1024)
|
|
158
|
+
if not data:
|
|
159
|
+
break
|
|
160
|
+
if self.process.stdin and not self.process.stdin.closed:
|
|
161
|
+
self.process.stdin.write(data)
|
|
162
|
+
self.process.stdin.flush()
|
|
163
|
+
except (OSError, ValueError, BrokenPipeError):
|
|
164
|
+
break
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
finally:
|
|
168
|
+
try:
|
|
169
|
+
if (
|
|
170
|
+
self.process
|
|
171
|
+
and self.process.stdin
|
|
172
|
+
and (not self.process.stdin.closed)
|
|
173
|
+
):
|
|
174
|
+
self.process.stdin.close()
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def _eval_loop(self):
|
|
179
|
+
while self.running and self.process and self.process.poll() is None:
|
|
180
|
+
time.sleep(15)
|
|
181
|
+
|
|
182
|
+
with self.lock:
|
|
183
|
+
if not self.buffer:
|
|
184
|
+
continue
|
|
185
|
+
full_text = "".join(self.buffer)
|
|
186
|
+
|
|
187
|
+
# Flaw 1 Fix: Sliding window overlap to prevent bridging blindness
|
|
188
|
+
overlap = full_text[-100:] if len(full_text) > 100 else ""
|
|
189
|
+
self.buffer.clear()
|
|
190
|
+
if overlap:
|
|
191
|
+
self.buffer.append(overlap)
|
|
192
|
+
|
|
193
|
+
clean_text = strip_ansi(full_text)
|
|
194
|
+
self.observer.log_action(
|
|
195
|
+
"CLI_Agent", clean_text, "cli_execution", len(clean_text) // 4
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
self.observer._evaluate_state("CLI_Agent")
|
|
200
|
+
except InterventionException as e:
|
|
201
|
+
print(f"\n\n[PANOPTICON GUILLOTINE TRIGGERED]")
|
|
202
|
+
print(f"[REASON]: {e.args[0]}")
|
|
203
|
+
print(
|
|
204
|
+
f"[LIVE INJECTION]: Interrupting agent and injecting "
|
|
205
|
+
f"correction...\n"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Flaw 1 Fix: The "Unkillable Zombie" SIGINT Interrupt
|
|
209
|
+
try:
|
|
210
|
+
if sys.platform != "win32":
|
|
211
|
+
self.process.send_signal(signal.SIGINT)
|
|
212
|
+
# Give the agent a split second to catch the interrupt
|
|
213
|
+
time.sleep(0.5)
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
# Write the injection payload as binary
|
|
219
|
+
if self.process.stdin and not self.process.stdin.closed:
|
|
220
|
+
payload = (e.course_correction + "\n").encode("utf-8")
|
|
221
|
+
self.process.stdin.write(payload)
|
|
222
|
+
self.process.stdin.flush()
|
|
223
|
+
except Exception as ex:
|
|
224
|
+
print(
|
|
225
|
+
f"[ERROR] Live injection failed or agent deadlocked: "
|
|
226
|
+
f"{ex}. Hard terminating."
|
|
227
|
+
)
|
|
228
|
+
self.process.terminate()
|
|
229
|
+
self.running = False
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main():
|
|
234
|
+
try:
|
|
235
|
+
from dotenv import load_dotenv
|
|
236
|
+
|
|
237
|
+
load_dotenv()
|
|
238
|
+
except ImportError:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
parser = argparse.ArgumentParser(
|
|
242
|
+
description="Panopticon: Production-Grade Immune System for AI CLIs"
|
|
243
|
+
)
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
"command", nargs=argparse.REMAINDER, help="The command to run, e.g., 'claude'"
|
|
246
|
+
)
|
|
247
|
+
args = parser.parse_args()
|
|
248
|
+
|
|
249
|
+
if not args.command:
|
|
250
|
+
print("Usage: panopticon [command]")
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
|
|
253
|
+
wrapper = CLIWrapper(args.command)
|
|
254
|
+
try:
|
|
255
|
+
wrapper.run()
|
|
256
|
+
except KeyboardInterrupt:
|
|
257
|
+
print("\n[PANOPTICON] Interrupted by user. Cleaning up...")
|
|
258
|
+
wrapper._cleanup()
|
|
259
|
+
sys.exit(0)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
print(f"[PANOPTICON ERROR] {e}")
|
|
262
|
+
wrapper._cleanup()
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: panopticon-cli
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: The Cognitive Immune System for Autonomous CLI Agents.
|
|
5
5
|
Author: Ak
|
|
6
6
|
License: MIT
|
|
@@ -34,6 +34,7 @@ Dynamic: license-file
|
|
|
34
34
|
|
|
35
35
|
[](https://pypi.org/project/panopticon-cli/)
|
|
36
36
|
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
[](https://github.com/ak495867/Panopticon/actions/workflows/ci.yml)
|
|
37
38
|
[](https://www.python.org/downloads/)
|
|
38
39
|
|
|
39
40
|
Autonomous CLI agents (like Claude Code, AutoGPT, and Antigravity) are amazing... right until they hallucinate, burn through $50 of your API credits, or confidently try to `rm -rf /` your hard drive.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "panopticon-cli"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.2"
|
|
8
8
|
description = "The Cognitive Immune System for Autonomous CLI Agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -41,3 +41,7 @@ dev = [
|
|
|
41
41
|
|
|
42
42
|
[project.scripts]
|
|
43
43
|
panopticon = "panopticon.cli:main"
|
|
44
|
+
|
|
45
|
+
[tool.black]
|
|
46
|
+
target_version = ["py39", "py310", "py311", "py312"]
|
|
47
|
+
line_length = 88
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from panopticon.cli import CLIWrapper, strip_ansi
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DummyStdinStream:
|
|
8
|
+
def __init__(self, data: bytes):
|
|
9
|
+
self.buffer = io.BytesIO(data)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DummyProcessStdin:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.written = bytearray()
|
|
15
|
+
self.closed = False
|
|
16
|
+
|
|
17
|
+
def write(self, data: bytes):
|
|
18
|
+
self.written.extend(data)
|
|
19
|
+
|
|
20
|
+
def flush(self):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def close(self):
|
|
24
|
+
self.closed = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DummyProcess:
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.stdin = DummyProcessStdin()
|
|
30
|
+
self.stdout = io.BytesIO()
|
|
31
|
+
|
|
32
|
+
def poll(self):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_strip_ansi():
|
|
37
|
+
# Basic red color text
|
|
38
|
+
colored_text = "\x1b[31mFatal Error\x1b[0m: File not found"
|
|
39
|
+
assert strip_ansi(colored_text) == "Fatal Error: File not found"
|
|
40
|
+
|
|
41
|
+
# Complex formatting (Bold + Green)
|
|
42
|
+
complex_text = "\x1b[1m\x1b[32mSuccess\x1b[0m"
|
|
43
|
+
assert strip_ansi(complex_text) == "Success"
|
|
44
|
+
|
|
45
|
+
# Loading spinner edge case
|
|
46
|
+
spinner_text = "\x1b[?25l\x1b[2K\x1b[1G⠋ Loading..."
|
|
47
|
+
assert "Loading..." in strip_ansi(spinner_text)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_read_stdin_forwards_input():
|
|
51
|
+
wrapper = CLIWrapper(["dummy"])
|
|
52
|
+
wrapper.running = True
|
|
53
|
+
wrapper.process = DummyProcess()
|
|
54
|
+
|
|
55
|
+
original_stdin = sys.stdin
|
|
56
|
+
try:
|
|
57
|
+
sys.stdin = DummyStdinStream(b"hello world\n")
|
|
58
|
+
wrapper._read_stdin()
|
|
59
|
+
finally:
|
|
60
|
+
sys.stdin = original_stdin
|
|
61
|
+
|
|
62
|
+
assert wrapper.process.stdin.written == b"hello world\n"
|
|
63
|
+
assert wrapper.process.stdin.closed is True
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
import threading
|
|
3
|
-
import sys
|
|
4
|
-
import time
|
|
5
|
-
import argparse
|
|
6
|
-
import re
|
|
7
|
-
import os
|
|
8
|
-
import signal
|
|
9
|
-
import codecs
|
|
10
|
-
from typing import List
|
|
11
|
-
from .observer import PanopticonObserver, InterventionException
|
|
12
|
-
from .policies import AdversarialLogicCheck, AntiLoopPolicy, BlacklistPolicy
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def strip_ansi(text: str) -> str:
|
|
16
|
-
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
17
|
-
return ansi_escape.sub("", text)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class CLIWrapper:
|
|
21
|
-
def __init__(self, command: List[str]):
|
|
22
|
-
self.command = command
|
|
23
|
-
target_agent = self.command[0] if self.command else "unknown"
|
|
24
|
-
|
|
25
|
-
self.observer = PanopticonObserver(
|
|
26
|
-
policies=[
|
|
27
|
-
BlacklistPolicy(forbidden_patterns=["rm -rf /", "DROP TABLE"]),
|
|
28
|
-
AntiLoopPolicy(window=3, threshold=0.85),
|
|
29
|
-
AdversarialLogicCheck(target_agent=target_agent),
|
|
30
|
-
]
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
self.process = None
|
|
34
|
-
self.buffer = []
|
|
35
|
-
self.running = False
|
|
36
|
-
self.lock = threading.Lock()
|
|
37
|
-
|
|
38
|
-
def run(self):
|
|
39
|
-
self.running = True
|
|
40
|
-
env = os.environ.copy()
|
|
41
|
-
env["PYTHONUNBUFFERED"] = "1"
|
|
42
|
-
env["FORCE_COLOR"] = "1"
|
|
43
|
-
env["CLICOLOR_FORCE"] = "1"
|
|
44
|
-
|
|
45
|
-
print(f"[PANOPTICON] Booting Immune System for: {' '.join(self.command)}")
|
|
46
|
-
print(
|
|
47
|
-
f"[PANOPTICON] Policies Active: Blacklist, AntiLoop (Fuzzy), AdversarialLogic"
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
# Flaw 2 Fix: Removing text=True to read raw binary bytes
|
|
51
|
-
self.process = subprocess.Popen(
|
|
52
|
-
self.command,
|
|
53
|
-
stdout=subprocess.PIPE,
|
|
54
|
-
stderr=subprocess.STDOUT,
|
|
55
|
-
stdin=subprocess.PIPE,
|
|
56
|
-
bufsize=0,
|
|
57
|
-
env=env,
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
t_out = threading.Thread(target=self._read_stdout)
|
|
61
|
-
t_out.daemon = True
|
|
62
|
-
t_out.start()
|
|
63
|
-
|
|
64
|
-
t_in = threading.Thread(target=self._forward_stdin)
|
|
65
|
-
t_in.daemon = True
|
|
66
|
-
t_in.start()
|
|
67
|
-
|
|
68
|
-
t_eval = threading.Thread(target=self._eval_loop)
|
|
69
|
-
t_eval.daemon = True
|
|
70
|
-
t_eval.start()
|
|
71
|
-
|
|
72
|
-
self.process.wait()
|
|
73
|
-
self.running = False
|
|
74
|
-
|
|
75
|
-
def _forward_stdin(self):
|
|
76
|
-
import select
|
|
77
|
-
try:
|
|
78
|
-
while self.running and self.process.poll() is None:
|
|
79
|
-
# Non-blocking check: only read if data is available
|
|
80
|
-
if sys.platform == "win32":
|
|
81
|
-
# Windows doesn't support select on stdin, so use a small sleep
|
|
82
|
-
time.sleep(0.1)
|
|
83
|
-
else:
|
|
84
|
-
# Unix/Linux: use select to check if stdin is readable
|
|
85
|
-
ready, _, _ = select.select([sys.stdin], [], [], 0.5)
|
|
86
|
-
if not ready:
|
|
87
|
-
continue
|
|
88
|
-
|
|
89
|
-
try:
|
|
90
|
-
line = sys.stdin.buffer.readline()
|
|
91
|
-
if not line: # EOF reached
|
|
92
|
-
break
|
|
93
|
-
self.process.stdin.write(line)
|
|
94
|
-
self.process.stdin.flush()
|
|
95
|
-
except (EOFError, OSError):
|
|
96
|
-
break
|
|
97
|
-
except Exception:
|
|
98
|
-
pass
|
|
99
|
-
|
|
100
|
-
def _read_stdout(self):
|
|
101
|
-
# Flaw 2 Fix: Safely decode multi-byte UTF-8 emojis incrementally
|
|
102
|
-
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
103
|
-
try:
|
|
104
|
-
while self.running and self.process.poll() is None:
|
|
105
|
-
byte_chunk = self.process.stdout.read(1)
|
|
106
|
-
if not byte_chunk:
|
|
107
|
-
break
|
|
108
|
-
|
|
109
|
-
# Mirror raw bytes to actual terminal seamlessly
|
|
110
|
-
sys.stdout.buffer.write(byte_chunk)
|
|
111
|
-
sys.stdout.buffer.flush()
|
|
112
|
-
|
|
113
|
-
char_str = decoder.decode(byte_chunk)
|
|
114
|
-
if char_str:
|
|
115
|
-
with self.lock:
|
|
116
|
-
self.buffer.append(char_str)
|
|
117
|
-
except Exception:
|
|
118
|
-
pass
|
|
119
|
-
|
|
120
|
-
def _eval_loop(self):
|
|
121
|
-
while self.running and self.process.poll() is None:
|
|
122
|
-
time.sleep(15)
|
|
123
|
-
|
|
124
|
-
with self.lock:
|
|
125
|
-
if not self.buffer:
|
|
126
|
-
continue
|
|
127
|
-
full_text = "".join(self.buffer)
|
|
128
|
-
|
|
129
|
-
# Flaw 1 Fix: Sliding window overlap to prevent bridging blindness
|
|
130
|
-
overlap = full_text[-100:] if len(full_text) > 100 else ""
|
|
131
|
-
self.buffer.clear()
|
|
132
|
-
if overlap:
|
|
133
|
-
self.buffer.append(overlap)
|
|
134
|
-
|
|
135
|
-
clean_text = strip_ansi(full_text)
|
|
136
|
-
self.observer.log_action(
|
|
137
|
-
"CLI_Agent", clean_text, "cli_execution", len(clean_text) // 4
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
try:
|
|
141
|
-
self.observer._evaluate_state("CLI_Agent")
|
|
142
|
-
except InterventionException as e:
|
|
143
|
-
print(f"\n\n[PANOPTICON GUILLOTINE TRIGGERED]")
|
|
144
|
-
print(f"[REASON]: {e.args[0]}")
|
|
145
|
-
print(
|
|
146
|
-
f"[LIVE INJECTION]: Interrupting agent and injecting correction...\n"
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
# Flaw 1 Fix: The "Unkillable Zombie" SIGINT Interrupt
|
|
150
|
-
try:
|
|
151
|
-
if sys.platform != "win32":
|
|
152
|
-
self.process.send_signal(signal.SIGINT)
|
|
153
|
-
# Give the agent a split second to catch the interrupt before writing to stdin
|
|
154
|
-
time.sleep(0.5)
|
|
155
|
-
except Exception:
|
|
156
|
-
pass
|
|
157
|
-
|
|
158
|
-
try:
|
|
159
|
-
# Write the injection payload as binary
|
|
160
|
-
payload = (e.course_correction + "\n").encode("utf-8")
|
|
161
|
-
self.process.stdin.write(payload)
|
|
162
|
-
self.process.stdin.flush()
|
|
163
|
-
except Exception as ex:
|
|
164
|
-
print(
|
|
165
|
-
f"[ERROR] Live injection failed or agent deadlocked: {ex}. Hard terminating."
|
|
166
|
-
)
|
|
167
|
-
self.process.terminate()
|
|
168
|
-
self.running = False
|
|
169
|
-
break
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def main():
|
|
173
|
-
try:
|
|
174
|
-
from dotenv import load_dotenv
|
|
175
|
-
|
|
176
|
-
load_dotenv()
|
|
177
|
-
except ImportError:
|
|
178
|
-
pass
|
|
179
|
-
|
|
180
|
-
parser = argparse.ArgumentParser(
|
|
181
|
-
description="Panopticon: Production-Grade Immune System for AI CLIs"
|
|
182
|
-
)
|
|
183
|
-
parser.add_argument(
|
|
184
|
-
"command", nargs=argparse.REMAINDER, help="The command to run, e.g., 'claude'"
|
|
185
|
-
)
|
|
186
|
-
args = parser.parse_args()
|
|
187
|
-
|
|
188
|
-
if not args.command:
|
|
189
|
-
print("Usage: panopticon [command]")
|
|
190
|
-
sys.exit(1)
|
|
191
|
-
|
|
192
|
-
wrapper = CLIWrapper(args.command)
|
|
193
|
-
wrapper.run()
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if __name__ == "__main__":
|
|
197
|
-
main()
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
from panopticon.cli import strip_ansi
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def test_strip_ansi():
|
|
5
|
-
# Basic red color text
|
|
6
|
-
colored_text = "\x1b[31mFatal Error\x1b[0m: File not found"
|
|
7
|
-
assert strip_ansi(colored_text) == "Fatal Error: File not found"
|
|
8
|
-
|
|
9
|
-
# Complex formatting (Bold + Green)
|
|
10
|
-
complex_text = "\x1b[1m\x1b[32mSuccess\x1b[0m"
|
|
11
|
-
assert strip_ansi(complex_text) == "Success"
|
|
12
|
-
|
|
13
|
-
# Loading spinner edge case
|
|
14
|
-
spinner_text = "\x1b[?25l\x1b[2K\x1b[1G⠋ Loading..."
|
|
15
|
-
assert "Loading..." in strip_ansi(spinner_text)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|