panopticon-cli 1.0.0__py3-none-any.whl
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/__init__.py +11 -0
- panopticon/cli.py +164 -0
- panopticon/memory.py +56 -0
- panopticon/observer.py +64 -0
- panopticon/policies.py +53 -0
- panopticon/sentinel.py +122 -0
- panopticon_cli-1.0.0.dist-info/METADATA +74 -0
- panopticon_cli-1.0.0.dist-info/RECORD +12 -0
- panopticon_cli-1.0.0.dist-info/WHEEL +5 -0
- panopticon_cli-1.0.0.dist-info/entry_points.txt +2 -0
- panopticon_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- panopticon_cli-1.0.0.dist-info/top_level.txt +1 -0
panopticon/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .observer import PanopticonObserver
|
|
2
|
+
from .policies import AdversarialLogicCheck, AntiLoopPolicy, BlacklistPolicy
|
|
3
|
+
from .memory import PersistentMemory
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"PanopticonObserver",
|
|
7
|
+
"AdversarialLogicCheck",
|
|
8
|
+
"AntiLoopPolicy",
|
|
9
|
+
"BlacklistPolicy",
|
|
10
|
+
"PersistentMemory"
|
|
11
|
+
]
|
panopticon/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
def strip_ansi(text: str) -> str:
|
|
15
|
+
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
|
16
|
+
return ansi_escape.sub('', text)
|
|
17
|
+
|
|
18
|
+
class CLIWrapper:
|
|
19
|
+
def __init__(self, command: List[str]):
|
|
20
|
+
self.command = command
|
|
21
|
+
target_agent = self.command[0] if self.command else "unknown"
|
|
22
|
+
|
|
23
|
+
self.observer = PanopticonObserver(policies=[
|
|
24
|
+
BlacklistPolicy(forbidden_patterns=["rm -rf /", "DROP TABLE"]),
|
|
25
|
+
AntiLoopPolicy(window=3, threshold=0.85),
|
|
26
|
+
AdversarialLogicCheck(target_agent=target_agent)
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
self.process = None
|
|
30
|
+
self.buffer = []
|
|
31
|
+
self.running = False
|
|
32
|
+
self.lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def run(self):
|
|
35
|
+
self.running = True
|
|
36
|
+
env = os.environ.copy()
|
|
37
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
38
|
+
env["FORCE_COLOR"] = "1"
|
|
39
|
+
env["CLICOLOR_FORCE"] = "1"
|
|
40
|
+
|
|
41
|
+
print(f"[PANOPTICON] Booting Immune System for: {' '.join(self.command)}")
|
|
42
|
+
print(f"[PANOPTICON] Policies Active: Blacklist, AntiLoop (Fuzzy), AdversarialLogic")
|
|
43
|
+
|
|
44
|
+
# Flaw 2 Fix: Removing text=True to read raw binary bytes
|
|
45
|
+
self.process = subprocess.Popen(
|
|
46
|
+
self.command,
|
|
47
|
+
stdout=subprocess.PIPE,
|
|
48
|
+
stderr=subprocess.STDOUT,
|
|
49
|
+
stdin=subprocess.PIPE,
|
|
50
|
+
bufsize=0,
|
|
51
|
+
env=env
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
t_out = threading.Thread(target=self._read_stdout)
|
|
55
|
+
t_out.daemon = True
|
|
56
|
+
t_out.start()
|
|
57
|
+
|
|
58
|
+
t_in = threading.Thread(target=self._forward_stdin)
|
|
59
|
+
t_in.daemon = True
|
|
60
|
+
t_in.start()
|
|
61
|
+
|
|
62
|
+
t_eval = threading.Thread(target=self._eval_loop)
|
|
63
|
+
t_eval.daemon = True
|
|
64
|
+
t_eval.start()
|
|
65
|
+
|
|
66
|
+
self.process.wait()
|
|
67
|
+
self.running = False
|
|
68
|
+
|
|
69
|
+
def _forward_stdin(self):
|
|
70
|
+
try:
|
|
71
|
+
while self.running and self.process.poll() is None:
|
|
72
|
+
# Read binary from stdin to pass natively to process
|
|
73
|
+
line = sys.stdin.buffer.readline()
|
|
74
|
+
if line:
|
|
75
|
+
self.process.stdin.write(line)
|
|
76
|
+
self.process.stdin.flush()
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def _read_stdout(self):
|
|
81
|
+
# Flaw 2 Fix: Safely decode multi-byte UTF-8 emojis incrementally
|
|
82
|
+
decoder = codecs.getincrementaldecoder('utf-8')(errors='replace')
|
|
83
|
+
try:
|
|
84
|
+
while self.running and self.process.poll() is None:
|
|
85
|
+
byte_chunk = self.process.stdout.read(1)
|
|
86
|
+
if not byte_chunk:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
# Mirror raw bytes to actual terminal seamlessly
|
|
90
|
+
sys.stdout.buffer.write(byte_chunk)
|
|
91
|
+
sys.stdout.buffer.flush()
|
|
92
|
+
|
|
93
|
+
char_str = decoder.decode(byte_chunk)
|
|
94
|
+
if char_str:
|
|
95
|
+
with self.lock:
|
|
96
|
+
self.buffer.append(char_str)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def _eval_loop(self):
|
|
101
|
+
while self.running and self.process.poll() is None:
|
|
102
|
+
time.sleep(15)
|
|
103
|
+
|
|
104
|
+
with self.lock:
|
|
105
|
+
if not self.buffer:
|
|
106
|
+
continue
|
|
107
|
+
full_text = "".join(self.buffer)
|
|
108
|
+
|
|
109
|
+
# Flaw 1 Fix: Sliding window overlap to prevent bridging blindness
|
|
110
|
+
overlap = full_text[-100:] if len(full_text) > 100 else ""
|
|
111
|
+
self.buffer.clear()
|
|
112
|
+
if overlap:
|
|
113
|
+
self.buffer.append(overlap)
|
|
114
|
+
|
|
115
|
+
clean_text = strip_ansi(full_text)
|
|
116
|
+
self.observer.log_action("CLI_Agent", clean_text, "cli_execution", len(clean_text) // 4)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
self.observer._evaluate_state("CLI_Agent")
|
|
120
|
+
except InterventionException as e:
|
|
121
|
+
print(f"\n\n[PANOPTICON GUILLOTINE TRIGGERED]")
|
|
122
|
+
print(f"[REASON]: {e.args[0]}")
|
|
123
|
+
print(f"[LIVE INJECTION]: Interrupting agent and injecting correction...\n")
|
|
124
|
+
|
|
125
|
+
# Flaw 1 Fix: The "Unkillable Zombie" SIGINT Interrupt
|
|
126
|
+
try:
|
|
127
|
+
if sys.platform != "win32":
|
|
128
|
+
self.process.send_signal(signal.SIGINT)
|
|
129
|
+
# Give the agent a split second to catch the interrupt before writing to stdin
|
|
130
|
+
time.sleep(0.5)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
# Write the injection payload as binary
|
|
136
|
+
payload = (e.course_correction + "\n").encode('utf-8')
|
|
137
|
+
self.process.stdin.write(payload)
|
|
138
|
+
self.process.stdin.flush()
|
|
139
|
+
except Exception as ex:
|
|
140
|
+
print(f"[ERROR] Live injection failed or agent deadlocked: {ex}. Hard terminating.")
|
|
141
|
+
self.process.terminate()
|
|
142
|
+
self.running = False
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
def main():
|
|
146
|
+
try:
|
|
147
|
+
from dotenv import load_dotenv
|
|
148
|
+
load_dotenv()
|
|
149
|
+
except ImportError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
parser = argparse.ArgumentParser(description="Panopticon: Production-Grade Immune System for AI CLIs")
|
|
153
|
+
parser.add_argument("command", nargs=argparse.REMAINDER, help="The command to run, e.g., 'claude'")
|
|
154
|
+
args = parser.parse_args()
|
|
155
|
+
|
|
156
|
+
if not args.command:
|
|
157
|
+
print("Usage: panopticon [command]")
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
wrapper = CLIWrapper(args.command)
|
|
161
|
+
wrapper.run()
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
panopticon/memory.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Dict
|
|
5
|
+
|
|
6
|
+
class PersistentMemory:
|
|
7
|
+
"""Production-grade procedural memory using keyword similarity routing."""
|
|
8
|
+
def __init__(self, db_path="panopticon_memory.db"):
|
|
9
|
+
self.db_path = db_path
|
|
10
|
+
self._init_db()
|
|
11
|
+
|
|
12
|
+
def _init_db(self):
|
|
13
|
+
# Flaw 3 Fix: Enable WAL and connection timeout for multi-process concurrency
|
|
14
|
+
with sqlite3.connect(self.db_path, timeout=10.0) as conn:
|
|
15
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
16
|
+
conn.execute("""
|
|
17
|
+
CREATE TABLE IF NOT EXISTS failures (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
20
|
+
reason TEXT,
|
|
21
|
+
correction TEXT
|
|
22
|
+
)
|
|
23
|
+
""")
|
|
24
|
+
|
|
25
|
+
def record_failure(self, reason: str, correction: str):
|
|
26
|
+
with sqlite3.connect(self.db_path, timeout=10.0) as conn:
|
|
27
|
+
conn.execute(
|
|
28
|
+
"INSERT INTO failures (reason, correction) VALUES (?, ?)",
|
|
29
|
+
(reason, correction)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def _extract_keywords(self, text: str) -> set:
|
|
33
|
+
words = re.findall(r'\b[a-zA-Z]{4,}\b', text.lower())
|
|
34
|
+
return set(words)
|
|
35
|
+
|
|
36
|
+
def get_relevant_failures(self, current_context: str, limit=3) -> List[Dict]:
|
|
37
|
+
"""Retrieves past failures that have the highest word overlap with the current context."""
|
|
38
|
+
current_keywords = self._extract_keywords(current_context)
|
|
39
|
+
if not current_keywords:
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
with sqlite3.connect(self.db_path, timeout=10.0) as conn:
|
|
43
|
+
cursor = conn.execute("SELECT reason, correction FROM failures")
|
|
44
|
+
all_failures = cursor.fetchall()
|
|
45
|
+
|
|
46
|
+
scored_failures = []
|
|
47
|
+
for reason, correction in all_failures:
|
|
48
|
+
reason_keywords = self._extract_keywords(reason)
|
|
49
|
+
# Calculate Jaccard-like overlap score
|
|
50
|
+
overlap = len(current_keywords.intersection(reason_keywords))
|
|
51
|
+
if overlap > 0:
|
|
52
|
+
scored_failures.append((overlap, {"reason": reason, "correction": correction}))
|
|
53
|
+
|
|
54
|
+
# Sort by highest overlap, then take top N
|
|
55
|
+
scored_failures.sort(key=lambda x: x[0], reverse=True)
|
|
56
|
+
return [f[1] for f in scored_failures[:limit]]
|
panopticon/observer.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import List, Dict, Callable
|
|
4
|
+
from .policies import BasePolicy
|
|
5
|
+
|
|
6
|
+
class InterventionException(Exception):
|
|
7
|
+
def __init__(self, message: str, course_correction: str):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.course_correction = course_correction
|
|
10
|
+
|
|
11
|
+
class PanopticonObserver:
|
|
12
|
+
def __init__(self, policies: List[BasePolicy] = None, telemetry_file: str = "panopticon_telemetry.jsonl"):
|
|
13
|
+
self.policies = policies or []
|
|
14
|
+
self.telemetry_stream = []
|
|
15
|
+
self.telemetry_file = telemetry_file
|
|
16
|
+
|
|
17
|
+
if os.path.exists(self.telemetry_file):
|
|
18
|
+
os.remove(self.telemetry_file)
|
|
19
|
+
|
|
20
|
+
def _broadcast(self, event_type: str, data: Dict):
|
|
21
|
+
"""Writes to a JSONL file so the Pantheon OS Dashboard can tail it in real-time."""
|
|
22
|
+
payload = {"event": event_type, **data}
|
|
23
|
+
with open(self.telemetry_file, "a") as f:
|
|
24
|
+
f.write(json.dumps(payload) + "\n")
|
|
25
|
+
|
|
26
|
+
def log_action(self, agent_name: str, thought: str, action: str, tokens_used: int):
|
|
27
|
+
entry = {
|
|
28
|
+
"agent": agent_name,
|
|
29
|
+
"thought": thought,
|
|
30
|
+
"action": action,
|
|
31
|
+
"tokens": tokens_used
|
|
32
|
+
}
|
|
33
|
+
# Flaw 2 Fix: Bounded memory to prevent leaks
|
|
34
|
+
self.telemetry_stream.append(entry)
|
|
35
|
+
if len(self.telemetry_stream) > 20:
|
|
36
|
+
self.telemetry_stream.pop(0)
|
|
37
|
+
self._broadcast("step", entry)
|
|
38
|
+
self._evaluate_state(agent_name)
|
|
39
|
+
|
|
40
|
+
def _evaluate_state(self, agent_name: str):
|
|
41
|
+
for policy in self.policies:
|
|
42
|
+
is_violating, reason, correction = policy.evaluate(self.telemetry_stream)
|
|
43
|
+
if is_violating:
|
|
44
|
+
self._broadcast("guillotine", {
|
|
45
|
+
"agent": agent_name,
|
|
46
|
+
"reason": reason,
|
|
47
|
+
"correction": correction
|
|
48
|
+
})
|
|
49
|
+
self._trigger_guillotine(agent_name, reason, correction)
|
|
50
|
+
|
|
51
|
+
def _trigger_guillotine(self, agent_name: str, reason: str, correction: str):
|
|
52
|
+
raise InterventionException(message=reason, course_correction=correction)
|
|
53
|
+
|
|
54
|
+
def watch(self, agent_name: str):
|
|
55
|
+
def decorator(func: Callable):
|
|
56
|
+
def wrapper(*args, **kwargs):
|
|
57
|
+
self._broadcast("start", {"agent": agent_name})
|
|
58
|
+
try:
|
|
59
|
+
return func(*args, **kwargs)
|
|
60
|
+
except InterventionException as e:
|
|
61
|
+
self._broadcast("interrupt", {"agent": agent_name, "correction": e.course_correction})
|
|
62
|
+
return {"status": "interrupted", "correction": e.course_correction}
|
|
63
|
+
return wrapper
|
|
64
|
+
return decorator
|
panopticon/policies.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Dict, Tuple
|
|
3
|
+
from .sentinel import Sentinel
|
|
4
|
+
|
|
5
|
+
class BasePolicy(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def evaluate(self, telemetry_stream: List[Dict]) -> Tuple[bool, str, str]:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
|
|
13
|
+
class AntiLoopPolicy(BasePolicy):
|
|
14
|
+
def __init__(self, window: int = 3, threshold: float = 0.85):
|
|
15
|
+
self.window = window
|
|
16
|
+
self.threshold = threshold
|
|
17
|
+
|
|
18
|
+
def evaluate(self, telemetry_stream: List[Dict]) -> Tuple[bool, str, str]:
|
|
19
|
+
if len(telemetry_stream) < self.window:
|
|
20
|
+
return False, "", ""
|
|
21
|
+
|
|
22
|
+
recent = [entry.get("thought", "").strip() for entry in telemetry_stream[-self.window:]]
|
|
23
|
+
if not recent[0]:
|
|
24
|
+
return False, "", ""
|
|
25
|
+
|
|
26
|
+
# Flaw 3 Fix: Fuzzy match chunks (≥85% similar) to ignore timestamps/spinners
|
|
27
|
+
ratios = [difflib.SequenceMatcher(None, recent[0], t).ratio() for t in recent]
|
|
28
|
+
|
|
29
|
+
if all(r >= self.threshold for r in ratios):
|
|
30
|
+
return True, "Heuristic: Fuzzy repetition loop detected", "[SYSTEM OVERRIDE] You are repeating the exact same actions. Try a different strategy."
|
|
31
|
+
return False, "", ""
|
|
32
|
+
|
|
33
|
+
class BlacklistPolicy(BasePolicy):
|
|
34
|
+
def __init__(self, forbidden_patterns: List[str]):
|
|
35
|
+
self.forbidden_patterns = forbidden_patterns
|
|
36
|
+
|
|
37
|
+
def evaluate(self, telemetry_stream: List[Dict]) -> Tuple[bool, str, str]:
|
|
38
|
+
if not telemetry_stream:
|
|
39
|
+
return False, "", ""
|
|
40
|
+
latest = telemetry_stream[-1].get("thought", "").lower()
|
|
41
|
+
for pattern in self.forbidden_patterns:
|
|
42
|
+
if pattern.lower() in latest:
|
|
43
|
+
return True, f"Blacklisted pattern detected: '{pattern}'", f"[SYSTEM OVERRIDE] You triggered a forbidden action ({pattern}). Reverse course immediately."
|
|
44
|
+
return False, "", ""
|
|
45
|
+
|
|
46
|
+
class AdversarialLogicCheck(BasePolicy):
|
|
47
|
+
def __init__(self, target_agent: str = "claude"):
|
|
48
|
+
self.sentinel = Sentinel(target_agent=target_agent)
|
|
49
|
+
|
|
50
|
+
def evaluate(self, telemetry_stream: List[Dict]) -> Tuple[bool, str, str]:
|
|
51
|
+
if len(telemetry_stream) >= 2:
|
|
52
|
+
return self.sentinel.evaluate_trajectory(telemetry_stream)
|
|
53
|
+
return False, "", ""
|
panopticon/sentinel.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import List, Dict, Tuple
|
|
4
|
+
from .memory import PersistentMemory
|
|
5
|
+
|
|
6
|
+
class Sentinel:
|
|
7
|
+
"""
|
|
8
|
+
The Universal Meta-Agent.
|
|
9
|
+
Dynamically routes its semantic evaluation to match the API of the agent it is observing.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, target_agent: str = "claude"):
|
|
12
|
+
self.target_agent = target_agent.lower()
|
|
13
|
+
self.memory = PersistentMemory()
|
|
14
|
+
|
|
15
|
+
def evaluate_trajectory(self, telemetry_stream: List[Dict]) -> Tuple[bool, str, str]:
|
|
16
|
+
if len(telemetry_stream) < 2:
|
|
17
|
+
return False, "", ""
|
|
18
|
+
|
|
19
|
+
recent_chunks = [e.get("thought", "")[:500] for e in telemetry_stream[-3:]]
|
|
20
|
+
context_str = " ".join(recent_chunks)
|
|
21
|
+
|
|
22
|
+
# Pull dynamically relevant failures based on current context
|
|
23
|
+
past_failures = self.memory.get_relevant_failures(current_context=context_str, limit=3)
|
|
24
|
+
|
|
25
|
+
prompt = f"""
|
|
26
|
+
Execution trace:
|
|
27
|
+
{json.dumps(recent_chunks)}
|
|
28
|
+
|
|
29
|
+
Relevant past failures (Do not repeat these mistakes):
|
|
30
|
+
{json.dumps(past_failures)}
|
|
31
|
+
|
|
32
|
+
Is the agent stuck in a loop, failing logical progress, or repeating a past recorded failure?
|
|
33
|
+
Respond ONLY in JSON:
|
|
34
|
+
{{"is_failing": true/false, "reason": "why if true", "correction_prompt": "directive prompt to inject to break the loop or correct the logic. empty if not failing"}}
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
result_text = self._route_to_llm(prompt)
|
|
38
|
+
|
|
39
|
+
# Flaw 4 Fix: Robust regex JSON extraction ignoring conversational fluff
|
|
40
|
+
import re
|
|
41
|
+
match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
|
42
|
+
if match:
|
|
43
|
+
result_text = match.group(0)
|
|
44
|
+
else:
|
|
45
|
+
return False, "", ""
|
|
46
|
+
|
|
47
|
+
data = json.loads(result_text)
|
|
48
|
+
|
|
49
|
+
if data.get("is_failing"):
|
|
50
|
+
reason = data.get("reason", "Failure detected")
|
|
51
|
+
correction = data.get("correction_prompt", "Discard approach.")
|
|
52
|
+
self.memory.record_failure(reason, correction)
|
|
53
|
+
return True, reason, correction
|
|
54
|
+
return False, "", ""
|
|
55
|
+
|
|
56
|
+
except Exception as e:
|
|
57
|
+
# Flaw 4 Fix: Don't fail silently on critical API configuration errors
|
|
58
|
+
error_str = str(e).lower()
|
|
59
|
+
if "api_key" in error_str or "auth" in error_str or "not found" in error_str:
|
|
60
|
+
raise RuntimeError(f"\n[PANOPTICON FATAL ERROR] Missing or invalid API key for {self.target_agent}: {e}")
|
|
61
|
+
|
|
62
|
+
# For random network timeouts, ignore and try again next loop
|
|
63
|
+
return False, "", ""
|
|
64
|
+
|
|
65
|
+
def _route_to_llm(self, prompt: str) -> str:
|
|
66
|
+
"""Dynamically switch the meta-agent API based on the target CLI command, with intelligent fallbacks."""
|
|
67
|
+
agent = self.target_agent
|
|
68
|
+
|
|
69
|
+
# 1. Explicit matching based on command name
|
|
70
|
+
if "agy" in agent or "gemini" in agent:
|
|
71
|
+
if os.environ.get("GEMINI_API_KEY"): return self._call_gemini(prompt)
|
|
72
|
+
elif "gpt" in agent or "codex" in agent or "openai" in agent:
|
|
73
|
+
if os.environ.get("OPENAI_API_KEY"): return self._call_openai(prompt)
|
|
74
|
+
elif "claude" in agent:
|
|
75
|
+
if os.environ.get("ANTHROPIC_API_KEY"): return self._call_anthropic(prompt)
|
|
76
|
+
|
|
77
|
+
# 2. Flaw 3 Fix: Intelligent Fallback if explicit match fails or agent is generic (e.g. 'python')
|
|
78
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
79
|
+
return self._call_anthropic(prompt)
|
|
80
|
+
elif os.environ.get("GEMINI_API_KEY"):
|
|
81
|
+
return self._call_gemini(prompt)
|
|
82
|
+
elif os.environ.get("OPENAI_API_KEY"):
|
|
83
|
+
return self._call_openai(prompt)
|
|
84
|
+
else:
|
|
85
|
+
raise RuntimeError("\n[PANOPTICON FATAL] No API keys found! Please set ANTHROPIC_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY.")
|
|
86
|
+
|
|
87
|
+
def _call_anthropic(self, prompt: str) -> str:
|
|
88
|
+
from anthropic import Anthropic
|
|
89
|
+
client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
|
|
90
|
+
resp = client.messages.create(
|
|
91
|
+
model="claude-3-5-haiku-20241022",
|
|
92
|
+
max_tokens=150,
|
|
93
|
+
temperature=0.0,
|
|
94
|
+
system="Strict meta-agent. Output JSON.",
|
|
95
|
+
messages=[{"role": "user", "content": prompt}]
|
|
96
|
+
)
|
|
97
|
+
return resp.content[0].text
|
|
98
|
+
|
|
99
|
+
def _call_openai(self, prompt: str) -> str:
|
|
100
|
+
from openai import OpenAI
|
|
101
|
+
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
|
|
102
|
+
resp = client.chat.completions.create(
|
|
103
|
+
model="gpt-4o-mini",
|
|
104
|
+
response_format={ "type": "json_object" },
|
|
105
|
+
messages=[
|
|
106
|
+
{"role": "system", "content": "Strict meta-agent. Output JSON."},
|
|
107
|
+
{"role": "user", "content": prompt}
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
return resp.choices[0].message.content
|
|
111
|
+
|
|
112
|
+
def _call_gemini(self, prompt: str) -> str:
|
|
113
|
+
import google.generativeai as genai
|
|
114
|
+
genai.configure(api_key=os.environ.get("GEMINI_API_KEY"))
|
|
115
|
+
model = genai.GenerativeModel('gemini-1.5-flash')
|
|
116
|
+
resp = model.generate_content(
|
|
117
|
+
"Strict meta-agent. Output JSON.\n\n" + prompt,
|
|
118
|
+
generation_config=genai.types.GenerationConfig(
|
|
119
|
+
response_mime_type="application/json",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return resp.text
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: panopticon-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The Cognitive Immune System for Autonomous CLI Agents.
|
|
5
|
+
Author: Ak
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
15
|
+
Classifier: Topic :: System :: Systems Administration
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: pydantic>=2.0.0
|
|
20
|
+
Requires-Dist: openai>=1.0.0
|
|
21
|
+
Requires-Dist: numpy>=1.24.0
|
|
22
|
+
Requires-Dist: anthropic>=0.30.0
|
|
23
|
+
Requires-Dist: google-generativeai>=0.5.0
|
|
24
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# PANOPTICON 👁️
|
|
32
|
+
|
|
33
|
+
> **The Cognitive Immune System (and glorified babysitter) for Autonomous CLI Agents.**
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/panopticon-cli/)
|
|
36
|
+
[](https://pypi.org/project/panopticon-cli/)
|
|
37
|
+
[](https://opensource.org/licenses/MIT)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](https://github.com/ak/panopticon/actions)
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
**Panopticon** is a zero-latency, non-blocking wrapper that silently watches your agent's terminal output. When the AI inevitably tries to do something incredibly stupid, Panopticon's **State Guillotine** drops.
|
|
44
|
+
|
|
45
|
+
It intercepts the rogue process, uses a Meta-Agent to figure out why your AI is crying, and **forcefully injects the correction directly into the agent's live `stdin` stream** like a disappointed senior developer taking over the keyboard. Oh, and it saves that failure to a SQLite database so the agent never makes the exact same mistake twice.
|
|
46
|
+
|
|
47
|
+
## Why you need this
|
|
48
|
+
|
|
49
|
+
- **Native TTY Wrapping:** Slap it in front of any CLI. Loading spinners, ANSI colors, and interactive prompts still work perfectly. We just spy on them natively.
|
|
50
|
+
- **The Policy Cascade (Iron Dome):**
|
|
51
|
+
- **Level 1 (Blacklist):** Instant, zero-cost kills for destructive actions. Because your AI *will* try to drop your production database eventually.
|
|
52
|
+
- **Level 2 (Fuzzy Heuristics):** Zero-cost math thresholds that catch repetitive loops. Stops the agent from running `cat missing_file.py` 300 times in a row.
|
|
53
|
+
- **Level 3 (Universal Semantic Logic):** Streams a sliding-window of the terminal to a Meta-Agent (Claude, GPT, or Gemini) to judge your sub-agent's poor life choices.
|
|
54
|
+
- **True Live Injection:** We don't just kill the agent and leave you hanging. Panopticon literally types the course-correction prompt into the interactive terminal for you, saving the session.
|
|
55
|
+
- **Persistent Semantic Memory:** AI agents have goldfish memory. We use SQLite and Jaccard keyword routing to permanently scar them with their past failures so they actually learn.
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Install globally from PyPI
|
|
61
|
+
pip install panopticon-cli
|
|
62
|
+
|
|
63
|
+
# Export your preferred API Key (Panopticon dynamically routes to whatever you actually pay for)
|
|
64
|
+
export OPENAI_API_KEY="sk-..."
|
|
65
|
+
# OR export ANTHROPIC_API_KEY="..."
|
|
66
|
+
# OR export GEMINI_API_KEY="..."
|
|
67
|
+
|
|
68
|
+
# Slap it in front of your agent!
|
|
69
|
+
panopticon claude
|
|
70
|
+
# OR
|
|
71
|
+
panopticon agy
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
See [USAGE.md](USAGE.md) for deeper configuration so you can start dropping the Guillotine.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
panopticon/__init__.py,sha256=v3e7coADLLuL6QbrMBCmLsYrXpwIZJ-3A_k4KhZ_NKM,293
|
|
2
|
+
panopticon/cli.py,sha256=NMUiyE6qEo_IgdwjzUMyI5tg_X-io0D8SIt4vJ3nkzw,5773
|
|
3
|
+
panopticon/memory.py,sha256=tsET5eL_V9sqN3PyNWT7kDiDB4sew-o8SXnipclFr-w,2292
|
|
4
|
+
panopticon/observer.py,sha256=cwhDuh_CpYaKHzPKhs16MFPvco1_QBooL5XZW3W4ohU,2625
|
|
5
|
+
panopticon/policies.py,sha256=-209r0LsG-6DAZRs4n2ZaX3ft0sFORuApLxYmkBkXd8,2204
|
|
6
|
+
panopticon/sentinel.py,sha256=qpb0Wuj3-oyafM_nDGMoR-4xQ2Hmdn4kh1csffP_Zow,5275
|
|
7
|
+
panopticon_cli-1.0.0.dist-info/licenses/LICENSE,sha256=RVE6q1ztFgMreAcgS3agYLyTymk3LfWEoCkYr4uQv64,1059
|
|
8
|
+
panopticon_cli-1.0.0.dist-info/METADATA,sha256=-Rvc6kvvzbUUsSHJtbeVilPyB3_tNz8yKQ1LhH75_J8,4096
|
|
9
|
+
panopticon_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
panopticon_cli-1.0.0.dist-info/entry_points.txt,sha256=gQ8sXaenMhuOvpNF7j1SZn4LyT8FovkoZtLNPYuF1MA,51
|
|
11
|
+
panopticon_cli-1.0.0.dist-info/top_level.txt,sha256=cwfjzY-pXdd-8YL4DE2KkDodExs8OXI_gUnFTJ8i1cQ,11
|
|
12
|
+
panopticon_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
panopticon
|