agentsguard 0.2.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.
- agentguard/__init__.py +1 -0
- agentguard/approval_engine.py +194 -0
- agentguard/audit.py +31 -0
- agentguard/cli.py +1189 -0
- agentguard/config.py +37 -0
- agentguard/guards.py +69 -0
- agentguard/presets.py +42 -0
- agentguard/relay_client.py +271 -0
- agentguard/risk_engine.py +86 -0
- agentguard/storage.py +72 -0
- agentguard/telegram_bot.py +569 -0
- agentsguard-0.2.0.dist-info/METADATA +143 -0
- agentsguard-0.2.0.dist-info/RECORD +17 -0
- agentsguard-0.2.0.dist-info/WHEEL +5 -0
- agentsguard-0.2.0.dist-info/entry_points.txt +2 -0
- agentsguard-0.2.0.dist-info/licenses/LICENSE +46 -0
- agentsguard-0.2.0.dist-info/top_level.txt +1 -0
agentguard/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from . import guards, presets, risk_engine, telegram_bot
|
|
5
|
+
from .audit import log_event, now_iso
|
|
6
|
+
from .config import load_config
|
|
7
|
+
from .storage import add_approval, add_instruction, list_approvals
|
|
8
|
+
|
|
9
|
+
DEFAULT_TIMEOUT = 120
|
|
10
|
+
DEFAULT_DEDUP_WINDOW = 10
|
|
11
|
+
|
|
12
|
+
# Decisions that count as "already answered" for idempotency purposes.
|
|
13
|
+
_FINAL_DECISIONS = (
|
|
14
|
+
"approved", "denied", "stopped", "auto_approved", "auto_denied", "timeout",
|
|
15
|
+
"allow_all", "guard_denied",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_RISK_RANK = {risk_engine.LOW: 0, risk_engine.MEDIUM: 1, risk_engine.HIGH: 2, risk_engine.CRITICAL: 3}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rank(level: str) -> int:
|
|
23
|
+
return _RISK_RANK.get(level, 1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotConfigured(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _recent_decision(command: str, window_seconds: int):
|
|
31
|
+
"""Return the most recent final decision for an identical command made within
|
|
32
|
+
`window_seconds`, else None. Lets a duplicate hook fire reuse a just-made
|
|
33
|
+
decision instead of posting a second, stale prompt."""
|
|
34
|
+
if window_seconds <= 0:
|
|
35
|
+
return None
|
|
36
|
+
now = datetime.now()
|
|
37
|
+
for rec in reversed(list_approvals()):
|
|
38
|
+
if rec.get("command") != command:
|
|
39
|
+
continue
|
|
40
|
+
decision = rec.get("decision")
|
|
41
|
+
if decision not in _FINAL_DECISIONS:
|
|
42
|
+
continue
|
|
43
|
+
ts = rec.get("timestamp")
|
|
44
|
+
try:
|
|
45
|
+
when = datetime.fromisoformat(ts) if ts else None
|
|
46
|
+
except ValueError:
|
|
47
|
+
when = None
|
|
48
|
+
if when is None:
|
|
49
|
+
return None
|
|
50
|
+
# The newest matching command decides it: reuse only if still fresh.
|
|
51
|
+
return decision if (now - when).total_seconds() <= window_seconds else None
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save_instruction(text: str) -> None:
|
|
56
|
+
add_instruction(text, now_iso())
|
|
57
|
+
log_event("instruction_added", text=text)
|
|
58
|
+
# A "don't touch X" instruction becomes an enforced guard.
|
|
59
|
+
keyword = guards.maybe_add_from_instruction(text)
|
|
60
|
+
if keyword:
|
|
61
|
+
log_event("guard_added", keyword=keyword, text=text)
|
|
62
|
+
cfg = load_config()
|
|
63
|
+
token, chat_id = cfg.get("telegram_bot_token"), cfg.get("telegram_chat_id")
|
|
64
|
+
if token and chat_id:
|
|
65
|
+
try:
|
|
66
|
+
telegram_bot.send_message(
|
|
67
|
+
token, chat_id,
|
|
68
|
+
f"đĄī¸ *Guard set* â I'll block commands touching `{keyword}`.",
|
|
69
|
+
)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def request_approval(command: str, timeout: int = DEFAULT_TIMEOUT, risk_override: str = None, tool_name: str = None, details: str = None, prompt: str = None, session_id: str = None) -> str:
|
|
75
|
+
"""Run the full approval flow for a command. Returns one of:
|
|
76
|
+
'approved', 'denied', 'stopped', 'auto_approved', 'auto_denied', 'timeout'.
|
|
77
|
+
|
|
78
|
+
`risk_override` forces the severity instead of classifying `command` â used
|
|
79
|
+
for non-Bash tools (Write/Edit), whose subject string isn't a shell command
|
|
80
|
+
and must not be run through the regex classifier (a path like `cat.py` would
|
|
81
|
+
falsely match LOW and auto-approve).
|
|
82
|
+
"""
|
|
83
|
+
risk = risk_override or risk_engine.classify(command)
|
|
84
|
+
log_event("approval_requested", command=command, risk=risk)
|
|
85
|
+
|
|
86
|
+
cfg = load_config()
|
|
87
|
+
|
|
88
|
+
# Cloud-relay backend: when paired with the relay (`backend: "relay"`), route
|
|
89
|
+
# the whole approval through it (guards, auto-rules, push, and the phone
|
|
90
|
+
# decision all live server-side). The Telegram path below is the alternative.
|
|
91
|
+
from . import relay_client
|
|
92
|
+
|
|
93
|
+
if relay_client.is_relay_configured(cfg):
|
|
94
|
+
kind = "Shell Command" if not tool_name or tool_name == "Bash" else "File Edit"
|
|
95
|
+
decision = relay_client.request_approval_relay(command, risk, kind=kind, timeout=timeout, cfg=cfg, details=details, prompt=prompt, session_id=session_id)
|
|
96
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": decision})
|
|
97
|
+
log_event("relay_decision", command=command, risk=risk, decision=decision)
|
|
98
|
+
return decision
|
|
99
|
+
|
|
100
|
+
# Phone-set guards ("Do not touch auth") hard-block any matching command,
|
|
101
|
+
# regardless of risk â before it can be auto-approved or reach the phone.
|
|
102
|
+
guard = guards.violated_by(command)
|
|
103
|
+
if guard:
|
|
104
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": "guard_denied"})
|
|
105
|
+
log_event("guard_blocked", command=command, risk=risk, guard=guard.get("keyword"))
|
|
106
|
+
token, chat_id = cfg.get("telegram_bot_token"), cfg.get("telegram_chat_id")
|
|
107
|
+
if token and chat_id:
|
|
108
|
+
try:
|
|
109
|
+
telegram_bot.send_message(
|
|
110
|
+
token, chat_id,
|
|
111
|
+
f"đĄī¸ *Blocked by your guard* (_{guard.get('text')}_):\n```\n{command}\n```",
|
|
112
|
+
)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return "guard_denied"
|
|
116
|
+
|
|
117
|
+
mirror_all = bool(cfg.get("mirror_all", False))
|
|
118
|
+
car_mode = bool(cfg.get("car_mode", False))
|
|
119
|
+
|
|
120
|
+
if car_mode:
|
|
121
|
+
# Car Mode: auto-approve anything below the ask threshold so routine
|
|
122
|
+
# commands don't interrupt you while driving; only risk >= threshold
|
|
123
|
+
# reaches the phone (CRITICAL is asked, not auto-denied, here).
|
|
124
|
+
threshold = cfg.get("car_ask_above", "high")
|
|
125
|
+
if _rank(risk) < _rank(threshold):
|
|
126
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": "auto_approved"})
|
|
127
|
+
log_event("car_auto_approved", command=command, risk=risk, threshold=threshold)
|
|
128
|
+
return "auto_approved"
|
|
129
|
+
elif not mirror_all:
|
|
130
|
+
# In mirror mode every command is forwarded to the phone â the auto-approve
|
|
131
|
+
# (LOW) and auto-deny (CRITICAL) shortcuts are skipped so nothing is decided
|
|
132
|
+
# without you.
|
|
133
|
+
if risk == risk_engine.LOW:
|
|
134
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": "auto_approved"})
|
|
135
|
+
log_event("auto_approved", command=command, risk=risk)
|
|
136
|
+
return "auto_approved"
|
|
137
|
+
|
|
138
|
+
if risk == risk_engine.CRITICAL:
|
|
139
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": "auto_denied"})
|
|
140
|
+
log_event("auto_denied", command=command, risk=risk, reason="critical_default")
|
|
141
|
+
return "auto_denied"
|
|
142
|
+
|
|
143
|
+
token = cfg.get("telegram_bot_token")
|
|
144
|
+
chat_id = cfg.get("telegram_chat_id")
|
|
145
|
+
if not token or not chat_id:
|
|
146
|
+
raise NotConfigured("Run `agentguard init` first.")
|
|
147
|
+
|
|
148
|
+
# Idempotency: a duplicate hook fire for a command decided moments ago reuses
|
|
149
|
+
# that decision instead of posting a second, stale Approve/Deny prompt.
|
|
150
|
+
window = int(cfg.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW))
|
|
151
|
+
prior = _recent_decision(command, window)
|
|
152
|
+
if prior is not None:
|
|
153
|
+
log_event("dedup_reused", command=command, risk=risk, decision=prior)
|
|
154
|
+
verb = "approved" if prior in ("approved", "auto_approved") else prior
|
|
155
|
+
try:
|
|
156
|
+
telegram_bot.send_message(
|
|
157
|
+
token,
|
|
158
|
+
chat_id,
|
|
159
|
+
f"âŠī¸ *Already answered* ({verb}) â not asking again:\n```\n{command}\n```",
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
return prior
|
|
164
|
+
|
|
165
|
+
quick = presets.for_command(command)
|
|
166
|
+
car_mode = bool(cfg.get("car_mode"))
|
|
167
|
+
openai_key = cfg.get("openai_api_key") or os.environ.get("OPENAI_API_KEY")
|
|
168
|
+
message_id = telegram_bot.send_approval_request(token, chat_id, command, risk, presets=quick, allow_all_label=tool_name)
|
|
169
|
+
log_event("telegram_sent", command=command, risk=risk, message_id=message_id)
|
|
170
|
+
if car_mode and cfg.get("car_speak", True):
|
|
171
|
+
telegram_bot.speak(token, chat_id, f"Approval needed. {risk} risk. Command: {command}. Reply approve, deny, or stop.")
|
|
172
|
+
|
|
173
|
+
decision = telegram_bot.wait_for_decision(
|
|
174
|
+
token=token,
|
|
175
|
+
chat_id=chat_id,
|
|
176
|
+
message_id=message_id,
|
|
177
|
+
command=command,
|
|
178
|
+
risk=risk,
|
|
179
|
+
timeout=timeout,
|
|
180
|
+
on_instruction=_save_instruction,
|
|
181
|
+
presets=quick,
|
|
182
|
+
allow_all_label=tool_name,
|
|
183
|
+
car_mode=car_mode,
|
|
184
|
+
openai_key=openai_key,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
add_approval({
|
|
188
|
+
"timestamp": now_iso(),
|
|
189
|
+
"command": command,
|
|
190
|
+
"risk": risk,
|
|
191
|
+
"decision": decision,
|
|
192
|
+
})
|
|
193
|
+
log_event(decision, command=command, risk=risk)
|
|
194
|
+
return decision
|
agentguard/audit.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from .config import AUDIT_FILE, ensure_dir
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def now_iso() -> str:
|
|
8
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def log_event(event: str, **fields) -> None:
|
|
12
|
+
ensure_dir()
|
|
13
|
+
record = {"timestamp": now_iso(), "event": event, **fields}
|
|
14
|
+
try:
|
|
15
|
+
items = json.loads(AUDIT_FILE.read_text(encoding="utf-8"))
|
|
16
|
+
if not isinstance(items, list):
|
|
17
|
+
items = []
|
|
18
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
19
|
+
items = []
|
|
20
|
+
items.append(record)
|
|
21
|
+
AUDIT_FILE.write_text(json.dumps(items, indent=2), encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_log() -> list:
|
|
25
|
+
if not AUDIT_FILE.exists():
|
|
26
|
+
return []
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(AUDIT_FILE.read_text(encoding="utf-8"))
|
|
29
|
+
return data if isinstance(data, list) else []
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
return []
|