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 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 []