connectonion 0.6.3__py3-none-any.whl → 0.6.5__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.
- connectonion/__init__.py +1 -1
- connectonion/cli/co_ai/agent.py +3 -3
- connectonion/cli/co_ai/main.py +2 -2
- connectonion/cli/co_ai/plugins/__init__.py +2 -3
- connectonion/cli/co_ai/plugins/system_reminder.py +154 -0
- connectonion/cli/co_ai/prompts/connectonion/concepts/trust.md +166 -208
- connectonion/cli/co_ai/prompts/system-reminders/agent.md +23 -0
- connectonion/cli/co_ai/prompts/system-reminders/plan_mode.md +13 -0
- connectonion/cli/co_ai/prompts/system-reminders/security.md +14 -0
- connectonion/cli/co_ai/prompts/system-reminders/simplicity.md +14 -0
- connectonion/cli/co_ai/tools/plan_mode.py +1 -4
- connectonion/cli/co_ai/tools/read.py +0 -6
- connectonion/cli/commands/copy_commands.py +21 -0
- connectonion/cli/commands/trust_commands.py +152 -0
- connectonion/cli/main.py +82 -0
- connectonion/core/llm.py +2 -2
- connectonion/docs/concepts/fast_rules.md +237 -0
- connectonion/docs/concepts/onboarding.md +465 -0
- connectonion/docs/concepts/plugins.md +2 -1
- connectonion/docs/concepts/trust.md +933 -192
- connectonion/docs/design-decisions/023-trust-policy-system-design.md +323 -0
- connectonion/docs/network/README.md +23 -1
- connectonion/docs/network/connect.md +135 -0
- connectonion/docs/network/host.md +73 -4
- connectonion/docs/useful_plugins/tool_approval.md +139 -0
- connectonion/network/__init__.py +7 -6
- connectonion/network/asgi/__init__.py +3 -0
- connectonion/network/asgi/http.py +125 -19
- connectonion/network/asgi/websocket.py +276 -15
- connectonion/network/connect.py +145 -29
- connectonion/network/host/auth.py +70 -67
- connectonion/network/host/routes.py +88 -3
- connectonion/network/host/server.py +100 -17
- connectonion/network/trust/__init__.py +27 -19
- connectonion/network/trust/factory.py +51 -24
- connectonion/network/trust/fast_rules.py +100 -0
- connectonion/network/trust/tools.py +316 -32
- connectonion/network/trust/trust_agent.py +403 -0
- connectonion/transcribe.py +1 -1
- connectonion/useful_plugins/__init__.py +2 -1
- connectonion/useful_plugins/tool_approval.py +233 -0
- {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/METADATA +1 -1
- {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/RECORD +45 -37
- connectonion/cli/co_ai/plugins/reminder.py +0 -76
- connectonion/cli/co_ai/plugins/shell_approval.py +0 -105
- connectonion/cli/co_ai/prompts/reminders/plan_mode.md +0 -34
- connectonion/cli/co_ai/reminders.py +0 -159
- connectonion/network/trust/prompts.py +0 -71
- {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/WHEEL +0 -0
- {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TrustAgent - A clear, method-based interface for trust management.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from connectonion.network.trust import TrustAgent
|
|
6
|
+
|
|
7
|
+
trust = TrustAgent("careful") # or "open", "strict", or path to policy file
|
|
8
|
+
|
|
9
|
+
# Check if request is allowed
|
|
10
|
+
decision = trust.should_allow("client-123", {"prompt": "hello"})
|
|
11
|
+
if decision.allow:
|
|
12
|
+
# process request
|
|
13
|
+
else:
|
|
14
|
+
print(decision.reason)
|
|
15
|
+
|
|
16
|
+
# Trust level operations
|
|
17
|
+
trust.promote_to_contact("client-123")
|
|
18
|
+
trust.block("bad-actor", reason="spam")
|
|
19
|
+
level = trust.get_level("client-123") # "stranger", "contact", "whitelist", "blocked"
|
|
20
|
+
|
|
21
|
+
# Admin operations
|
|
22
|
+
trust.is_admin("client-123")
|
|
23
|
+
trust.add_admin("new-admin")
|
|
24
|
+
|
|
25
|
+
Extensibility:
|
|
26
|
+
Subclass TrustAgent to customize behavior. All methods can be overridden.
|
|
27
|
+
|
|
28
|
+
class MyTrustAgent(TrustAgent):
|
|
29
|
+
'''Custom trust with database-backed storage.'''
|
|
30
|
+
|
|
31
|
+
def is_admin(self, client_id: str) -> bool:
|
|
32
|
+
'''Check admin from database instead of file.'''
|
|
33
|
+
return self.db.is_admin(client_id)
|
|
34
|
+
|
|
35
|
+
def promote_to_contact(self, client_id: str) -> str:
|
|
36
|
+
'''Store contacts in database.'''
|
|
37
|
+
self.db.add_contact(client_id)
|
|
38
|
+
return f"{client_id} promoted to contact."
|
|
39
|
+
|
|
40
|
+
# Use custom trust agent
|
|
41
|
+
host(create_agent, trust=MyTrustAgent("careful"))
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Optional
|
|
47
|
+
|
|
48
|
+
from .fast_rules import parse_policy, evaluate_request
|
|
49
|
+
from .tools import (
|
|
50
|
+
is_whitelisted as _is_whitelisted,
|
|
51
|
+
is_blocked as _is_blocked,
|
|
52
|
+
is_contact as _is_contact,
|
|
53
|
+
is_stranger as _is_stranger,
|
|
54
|
+
promote_to_contact as _promote_to_contact,
|
|
55
|
+
promote_to_whitelist as _promote_to_whitelist,
|
|
56
|
+
demote_to_contact as _demote_to_contact,
|
|
57
|
+
demote_to_stranger as _demote_to_stranger,
|
|
58
|
+
block as _block,
|
|
59
|
+
unblock as _unblock,
|
|
60
|
+
get_level as _get_level,
|
|
61
|
+
# Admin functions
|
|
62
|
+
is_admin as _is_admin,
|
|
63
|
+
is_super_admin as _is_super_admin,
|
|
64
|
+
get_self_address as _get_self_address,
|
|
65
|
+
add_admin as _add_admin,
|
|
66
|
+
remove_admin as _remove_admin,
|
|
67
|
+
)
|
|
68
|
+
from .factory import PROMPTS_DIR, TRUST_LEVELS
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Decision:
|
|
73
|
+
"""Result of should_allow() check."""
|
|
74
|
+
allow: bool
|
|
75
|
+
reason: str
|
|
76
|
+
used_llm: bool = False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TrustAgent:
|
|
80
|
+
"""
|
|
81
|
+
Trust management with a clear, method-based interface.
|
|
82
|
+
|
|
83
|
+
All trust operations in one place:
|
|
84
|
+
- should_allow() - Check if request is allowed (fast rules + LLM fallback)
|
|
85
|
+
- verify_invite() / verify_payment() - Onboarding
|
|
86
|
+
- promote_to_contact() / promote_to_whitelist() - Promotion
|
|
87
|
+
- demote_to_contact() / demote_to_stranger() - Demotion
|
|
88
|
+
- block() / unblock() - Blocking
|
|
89
|
+
- get_level() - Query current level
|
|
90
|
+
- is_admin() / is_super_admin() - Admin checks
|
|
91
|
+
- add_admin() / remove_admin() - Admin management
|
|
92
|
+
|
|
93
|
+
Extensibility:
|
|
94
|
+
All methods can be overridden in subclasses for custom storage,
|
|
95
|
+
authentication backends, or business logic. See module docstring.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, trust: str = "careful", *, api_key: str = None, model: str = "co/gpt-4o-mini"):
|
|
99
|
+
"""
|
|
100
|
+
Create a TrustAgent.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
trust: Trust level ("open", "careful", "strict") or path to policy file
|
|
104
|
+
api_key: Optional API key for LLM (only needed if using 'ask' default)
|
|
105
|
+
model: Model to use for LLM decisions
|
|
106
|
+
"""
|
|
107
|
+
self.trust = trust
|
|
108
|
+
self.api_key = api_key
|
|
109
|
+
self.model = model
|
|
110
|
+
|
|
111
|
+
# Load policy and parse config
|
|
112
|
+
self._config, self._prompt = self._load_policy(trust)
|
|
113
|
+
|
|
114
|
+
# Lazy-loaded LLM agent (only created if needed)
|
|
115
|
+
self._llm_agent = None
|
|
116
|
+
|
|
117
|
+
def _load_policy(self, trust: str) -> tuple[dict, str]:
|
|
118
|
+
"""Load policy file and parse YAML config."""
|
|
119
|
+
# Trust level -> load from prompts/trust/{level}.md
|
|
120
|
+
if trust.lower() in TRUST_LEVELS:
|
|
121
|
+
policy_path = PROMPTS_DIR / f"{trust.lower()}.md"
|
|
122
|
+
if policy_path.exists():
|
|
123
|
+
text = policy_path.read_text(encoding='utf-8')
|
|
124
|
+
return parse_policy(text)
|
|
125
|
+
return {}, ""
|
|
126
|
+
|
|
127
|
+
# File path
|
|
128
|
+
path = Path(trust)
|
|
129
|
+
if path.exists() and path.is_file():
|
|
130
|
+
text = path.read_text(encoding='utf-8')
|
|
131
|
+
return parse_policy(text)
|
|
132
|
+
|
|
133
|
+
# Inline policy text
|
|
134
|
+
if trust.startswith('---'):
|
|
135
|
+
return parse_policy(trust)
|
|
136
|
+
|
|
137
|
+
# Unknown - empty config
|
|
138
|
+
return {}, ""
|
|
139
|
+
|
|
140
|
+
# === Main Decision Method ===
|
|
141
|
+
|
|
142
|
+
def should_allow(self, client_id: str, request: dict = None) -> Decision:
|
|
143
|
+
"""
|
|
144
|
+
Check if a request should be allowed.
|
|
145
|
+
|
|
146
|
+
Runs fast rules first (no LLM). Only uses LLM if config has 'default: ask'.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
client_id: The client making the request
|
|
150
|
+
request: Optional request data (may contain invite_code, payment)
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Decision with allow (bool) and reason (str)
|
|
154
|
+
"""
|
|
155
|
+
request = request or {}
|
|
156
|
+
|
|
157
|
+
# Fast rules (no LLM)
|
|
158
|
+
result = evaluate_request(self._config, client_id, request)
|
|
159
|
+
|
|
160
|
+
if result == 'allow':
|
|
161
|
+
return Decision(allow=True, reason="Allowed by fast rules")
|
|
162
|
+
elif result == 'deny':
|
|
163
|
+
return Decision(allow=False, reason="Denied by fast rules")
|
|
164
|
+
|
|
165
|
+
# result is None -> need LLM decision
|
|
166
|
+
return self._llm_decide(client_id, request)
|
|
167
|
+
|
|
168
|
+
def _llm_decide(self, client_id: str, request: dict) -> Decision:
|
|
169
|
+
"""Use LLM to make trust decision (only for 'ask' cases)."""
|
|
170
|
+
from ...core.agent import Agent
|
|
171
|
+
from ...llm_do import llm_do
|
|
172
|
+
from pydantic import BaseModel
|
|
173
|
+
|
|
174
|
+
class TrustDecision(BaseModel):
|
|
175
|
+
allow: bool
|
|
176
|
+
reason: str
|
|
177
|
+
|
|
178
|
+
# Build context for LLM
|
|
179
|
+
level = self.get_level(client_id)
|
|
180
|
+
prompt = f"""Evaluate this trust request:
|
|
181
|
+
- client_id: {client_id}
|
|
182
|
+
- current_level: {level}
|
|
183
|
+
- request: {request}
|
|
184
|
+
|
|
185
|
+
Should this client be allowed access?"""
|
|
186
|
+
|
|
187
|
+
decision = llm_do(
|
|
188
|
+
prompt,
|
|
189
|
+
output=TrustDecision,
|
|
190
|
+
system_prompt=self._prompt or "You are a trust evaluation agent. Decide if the request should be allowed.",
|
|
191
|
+
api_key=self.api_key,
|
|
192
|
+
model=self.model,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return Decision(allow=decision.allow, reason=decision.reason, used_llm=True)
|
|
196
|
+
|
|
197
|
+
# === Verification (Onboarding) ===
|
|
198
|
+
|
|
199
|
+
def verify_invite(self, client_id: str, invite_code: str) -> bool:
|
|
200
|
+
"""
|
|
201
|
+
Verify invite code. Promotes to contact if valid.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
client_id: Client to verify
|
|
205
|
+
invite_code: The invite code provided
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
True if valid and promoted, False otherwise
|
|
209
|
+
"""
|
|
210
|
+
valid_codes = self._config.get('onboard', {}).get('invite_code', [])
|
|
211
|
+
if invite_code in valid_codes:
|
|
212
|
+
self.promote_to_contact(client_id)
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
def verify_payment(self, client_id: str, amount: float) -> bool:
|
|
217
|
+
"""
|
|
218
|
+
Verify payment via oo-api transfer verification.
|
|
219
|
+
|
|
220
|
+
Calls the oo-api to check if client_id transferred at least `amount`
|
|
221
|
+
to this agent's address within the last 5 minutes.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
client_id: Client to verify (their address)
|
|
225
|
+
amount: Minimum payment amount required
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
True if transfer verified and promoted, False otherwise
|
|
229
|
+
"""
|
|
230
|
+
required = self._config.get('onboard', {}).get('payment')
|
|
231
|
+
if not required:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
# Use configured amount if not specified
|
|
235
|
+
min_amount = amount if amount > 0 else required
|
|
236
|
+
|
|
237
|
+
# Get self address (agent's address)
|
|
238
|
+
self_addr = self.get_self_address()
|
|
239
|
+
if not self_addr:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
# Call oo-api to verify transfer
|
|
243
|
+
if self._verify_transfer_via_api(client_id, self_addr, min_amount):
|
|
244
|
+
self.promote_to_contact(client_id)
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def _verify_transfer_via_api(self, from_addr: str, to_addr: str, min_amount: float) -> bool:
|
|
249
|
+
"""Call oo-api to verify a transfer was made."""
|
|
250
|
+
import os
|
|
251
|
+
import json
|
|
252
|
+
import time
|
|
253
|
+
from pathlib import Path
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
import httpx
|
|
257
|
+
except ImportError:
|
|
258
|
+
# httpx not available, fall back to simple amount check
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
# Load agent's keys for signing the request
|
|
262
|
+
from ... import address as addr
|
|
263
|
+
|
|
264
|
+
co_dir = Path.cwd() / '.co'
|
|
265
|
+
keys = addr.load(co_dir)
|
|
266
|
+
if not keys:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
# Determine API URL
|
|
270
|
+
base_url = os.environ.get('OPENONION_BASE_URL', 'https://oo.openonion.ai')
|
|
271
|
+
if os.environ.get('OPENONION_DEV'):
|
|
272
|
+
base_url = 'http://localhost:8000'
|
|
273
|
+
|
|
274
|
+
# Create signed auth request
|
|
275
|
+
timestamp = int(time.time())
|
|
276
|
+
message = f"ConnectOnion-Auth-{keys['public_key']}-{timestamp}"
|
|
277
|
+
signature = addr.sign(keys, message.encode()).hex()
|
|
278
|
+
|
|
279
|
+
# Get JWT token
|
|
280
|
+
auth_response = httpx.post(
|
|
281
|
+
f"{base_url}/auth",
|
|
282
|
+
json={
|
|
283
|
+
"public_key": keys['public_key'],
|
|
284
|
+
"message": message,
|
|
285
|
+
"signature": signature
|
|
286
|
+
},
|
|
287
|
+
timeout=10
|
|
288
|
+
)
|
|
289
|
+
if auth_response.status_code != 200:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
token = auth_response.json().get('token')
|
|
293
|
+
if not token:
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
# Call verify endpoint
|
|
297
|
+
verify_response = httpx.post(
|
|
298
|
+
f"{base_url}/api/v1/onboard/verify",
|
|
299
|
+
json={
|
|
300
|
+
"from_address": from_addr,
|
|
301
|
+
"min_amount": min_amount
|
|
302
|
+
},
|
|
303
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
304
|
+
timeout=10
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if verify_response.status_code == 200:
|
|
308
|
+
result = verify_response.json()
|
|
309
|
+
return result.get('verified', False)
|
|
310
|
+
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
# === Promotion ===
|
|
314
|
+
|
|
315
|
+
def promote_to_contact(self, client_id: str) -> str:
|
|
316
|
+
"""Stranger -> Contact"""
|
|
317
|
+
return _promote_to_contact(client_id)
|
|
318
|
+
|
|
319
|
+
def promote_to_whitelist(self, client_id: str) -> str:
|
|
320
|
+
"""Contact -> Whitelist"""
|
|
321
|
+
return _promote_to_whitelist(client_id)
|
|
322
|
+
|
|
323
|
+
# === Demotion ===
|
|
324
|
+
|
|
325
|
+
def demote_to_contact(self, client_id: str) -> str:
|
|
326
|
+
"""Whitelist -> Contact"""
|
|
327
|
+
return _demote_to_contact(client_id)
|
|
328
|
+
|
|
329
|
+
def demote_to_stranger(self, client_id: str) -> str:
|
|
330
|
+
"""Contact -> Stranger"""
|
|
331
|
+
return _demote_to_stranger(client_id)
|
|
332
|
+
|
|
333
|
+
# === Blocking ===
|
|
334
|
+
|
|
335
|
+
def block(self, client_id: str, reason: str = "") -> str:
|
|
336
|
+
"""Add to blocklist."""
|
|
337
|
+
return _block(client_id, reason)
|
|
338
|
+
|
|
339
|
+
def unblock(self, client_id: str) -> str:
|
|
340
|
+
"""Remove from blocklist."""
|
|
341
|
+
return _unblock(client_id)
|
|
342
|
+
|
|
343
|
+
# === Queries ===
|
|
344
|
+
|
|
345
|
+
def get_level(self, client_id: str) -> str:
|
|
346
|
+
"""
|
|
347
|
+
Get client's current trust level.
|
|
348
|
+
|
|
349
|
+
Returns: "stranger", "contact", "whitelist", or "blocked"
|
|
350
|
+
"""
|
|
351
|
+
return _get_level(client_id)
|
|
352
|
+
|
|
353
|
+
def is_whitelisted(self, client_id: str) -> bool:
|
|
354
|
+
"""Check if client is whitelisted."""
|
|
355
|
+
return _is_whitelisted(client_id)
|
|
356
|
+
|
|
357
|
+
def is_blocked(self, client_id: str) -> bool:
|
|
358
|
+
"""Check if client is blocked."""
|
|
359
|
+
return _is_blocked(client_id)
|
|
360
|
+
|
|
361
|
+
def is_contact(self, client_id: str) -> bool:
|
|
362
|
+
"""Check if client is a contact."""
|
|
363
|
+
return _is_contact(client_id)
|
|
364
|
+
|
|
365
|
+
def is_stranger(self, client_id: str) -> bool:
|
|
366
|
+
"""Check if client is a stranger."""
|
|
367
|
+
return _is_stranger(client_id)
|
|
368
|
+
|
|
369
|
+
# === Admin Management ===
|
|
370
|
+
# Instance methods for easy subclass overloading.
|
|
371
|
+
# Override these to customize admin logic (e.g., database-backed, LDAP, etc.)
|
|
372
|
+
|
|
373
|
+
def is_admin(self, client_id: str) -> bool:
|
|
374
|
+
"""Check if client is an admin. Override for custom admin logic."""
|
|
375
|
+
return _is_admin(client_id)
|
|
376
|
+
|
|
377
|
+
def is_super_admin(self, client_id: str) -> bool:
|
|
378
|
+
"""Check if client is super admin (self address). Override for custom logic."""
|
|
379
|
+
return _is_super_admin(client_id)
|
|
380
|
+
|
|
381
|
+
def get_self_address(self) -> str | None:
|
|
382
|
+
"""Get self address (super admin)."""
|
|
383
|
+
return _get_self_address()
|
|
384
|
+
|
|
385
|
+
def add_admin(self, admin_id: str) -> str:
|
|
386
|
+
"""Add an admin. Super admin only. Override for custom storage."""
|
|
387
|
+
return _add_admin(admin_id)
|
|
388
|
+
|
|
389
|
+
def remove_admin(self, admin_id: str) -> str:
|
|
390
|
+
"""Remove an admin. Super admin only. Override for custom storage."""
|
|
391
|
+
return _remove_admin(admin_id)
|
|
392
|
+
|
|
393
|
+
# === Config Access ===
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def config(self) -> dict:
|
|
397
|
+
"""Get the parsed YAML config."""
|
|
398
|
+
return self._config
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def prompt(self) -> str:
|
|
402
|
+
"""Get the markdown prompt (for LLM decisions)."""
|
|
403
|
+
return self._prompt
|
connectonion/transcribe.py
CHANGED
|
@@ -70,7 +70,7 @@ def _get_api_key(model: str) -> str:
|
|
|
70
70
|
api_key = os.getenv("OPENONION_API_KEY")
|
|
71
71
|
if not api_key:
|
|
72
72
|
# Try loading from config file
|
|
73
|
-
config_path = Path.home() / ".
|
|
73
|
+
config_path = Path.home() / ".co" / "config.toml"
|
|
74
74
|
if config_path.exists():
|
|
75
75
|
import toml
|
|
76
76
|
config = toml.load(config_path)
|
|
@@ -18,5 +18,6 @@ from .gmail_plugin import gmail_plugin
|
|
|
18
18
|
from .calendar_plugin import calendar_plugin
|
|
19
19
|
from .ui_stream import ui_stream
|
|
20
20
|
from .system_reminder import system_reminder
|
|
21
|
+
from .tool_approval import tool_approval
|
|
21
22
|
|
|
22
|
-
__all__ = ['re_act', 'eval', 'image_result_formatter', 'shell_approval', 'gmail_plugin', 'calendar_plugin', 'ui_stream', 'system_reminder']
|
|
23
|
+
__all__ = ['re_act', 'eval', 'image_result_formatter', 'shell_approval', 'gmail_plugin', 'calendar_plugin', 'ui_stream', 'system_reminder', 'tool_approval']
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Web-based tool approval plugin - request user approval before dangerous tools
|
|
3
|
+
LLM-Note:
|
|
4
|
+
Dependencies: imports from [core/events.py] | imported by [useful_plugins/__init__.py] | tested by [tests/unit/test_tool_approval.py]
|
|
5
|
+
Data flow: before_each_tool fires → check if dangerous tool → io.send(approval_needed) → io.receive() blocks → approved: continue, rejected: raise ValueError
|
|
6
|
+
State/Effects: stores approved_tools in session for "session" scope approvals | blocks on io.receive() until client responds | logs all approval decisions via agent.logger
|
|
7
|
+
Integration: exposes tool_approval plugin list | uses agent.io for WebSocket communication | requires client to handle "approval_needed" events
|
|
8
|
+
Errors: raises ValueError on rejection (stops batch, feedback sent to LLM)
|
|
9
|
+
|
|
10
|
+
Tool Approval Plugin - Request client approval before executing dangerous tools.
|
|
11
|
+
|
|
12
|
+
WebSocket-only. Uses io.send/receive pattern:
|
|
13
|
+
1. Sends {type: "approval_needed", tool, arguments} to client
|
|
14
|
+
2. Blocks until client responds with {approved: bool, scope?, feedback?}
|
|
15
|
+
3. If approved: execute tool (optionally save to session memory)
|
|
16
|
+
4. If rejected: raise ValueError, stopping batch, LLM sees feedback
|
|
17
|
+
|
|
18
|
+
Tool Classification:
|
|
19
|
+
- SAFE_TOOLS: Read-only operations (read, glob, grep, etc.) - never need approval
|
|
20
|
+
- DANGEROUS_TOOLS: Write/execute operations (bash, write, edit, etc.) - always need approval
|
|
21
|
+
- Unknown tools: Treated as safe (no approval needed)
|
|
22
|
+
|
|
23
|
+
Session Memory:
|
|
24
|
+
- scope="once": Approve for this call only
|
|
25
|
+
- scope="session": Approve for rest of session (no re-prompting)
|
|
26
|
+
|
|
27
|
+
Rejection Behavior:
|
|
28
|
+
- Raises ValueError with user feedback
|
|
29
|
+
- Stops entire tool batch (remaining tools skipped)
|
|
30
|
+
- LLM receives error message and can adjust approach
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
from connectonion import Agent
|
|
34
|
+
from connectonion.useful_plugins import tool_approval
|
|
35
|
+
|
|
36
|
+
agent = Agent("assistant", tools=[bash, write], plugins=[tool_approval])
|
|
37
|
+
|
|
38
|
+
Client Protocol:
|
|
39
|
+
# Receive from server:
|
|
40
|
+
{"type": "approval_needed", "tool": "bash", "arguments": {"command": "npm install"}}
|
|
41
|
+
|
|
42
|
+
# Send response:
|
|
43
|
+
{"approved": true, "scope": "session"} # Approve for session
|
|
44
|
+
{"approved": true, "scope": "once"} # Approve once
|
|
45
|
+
{"approved": false, "feedback": "Use yarn instead"} # Reject with feedback
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from typing import TYPE_CHECKING
|
|
49
|
+
|
|
50
|
+
from ..core.events import before_each_tool
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from ..core.agent import Agent
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Tools that NEVER need approval (read-only, safe)
|
|
57
|
+
# These tools cannot modify system state or have external side effects.
|
|
58
|
+
# Add new read-only tools here to skip approval prompts.
|
|
59
|
+
SAFE_TOOLS = {
|
|
60
|
+
# File reading - read contents without modification
|
|
61
|
+
'read', 'read_file',
|
|
62
|
+
# Search operations - find files/content without modification
|
|
63
|
+
'glob', 'grep', 'search',
|
|
64
|
+
# Info operations - query metadata only
|
|
65
|
+
'list_files', 'get_file_info',
|
|
66
|
+
# Agent operations - sub-agents handle their own approval
|
|
67
|
+
'task',
|
|
68
|
+
# Documentation - load reference materials
|
|
69
|
+
'load_guide',
|
|
70
|
+
# Planning - state management without side effects
|
|
71
|
+
'enter_plan_mode', 'exit_plan_mode', 'write_plan',
|
|
72
|
+
# Task management - read-only task status
|
|
73
|
+
'task_output',
|
|
74
|
+
# User interaction - prompts user, not system modification
|
|
75
|
+
'ask_user',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Tools that ALWAYS need approval (destructive/side-effects)
|
|
79
|
+
# These tools can modify files, execute code, or have external effects.
|
|
80
|
+
# User approval required before execution in web mode.
|
|
81
|
+
DANGEROUS_TOOLS = {
|
|
82
|
+
# Shell execution - arbitrary command execution
|
|
83
|
+
'bash', 'shell', 'run', 'run_in_dir',
|
|
84
|
+
# File modification - write/edit file contents
|
|
85
|
+
'write', 'edit', 'multi_edit',
|
|
86
|
+
# Background tasks - long-running command execution
|
|
87
|
+
'run_background',
|
|
88
|
+
# Task control - terminate running processes
|
|
89
|
+
'kill_task',
|
|
90
|
+
# External communication - send data outside system
|
|
91
|
+
'send_email', 'post',
|
|
92
|
+
# Deletion - remove files/resources
|
|
93
|
+
'delete', 'remove',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Session state helpers for approval memory
|
|
98
|
+
# These functions manage the session['approval'] dict which tracks
|
|
99
|
+
# which tools have been approved for the current session.
|
|
100
|
+
|
|
101
|
+
def _init_approval_state(session: dict) -> None:
|
|
102
|
+
"""Initialize approval state in session if not present.
|
|
103
|
+
|
|
104
|
+
Creates session['approval']['approved_tools'] dict for storing
|
|
105
|
+
tool approvals with scope='session'.
|
|
106
|
+
"""
|
|
107
|
+
if 'approval' not in session:
|
|
108
|
+
session['approval'] = {
|
|
109
|
+
'approved_tools': {}, # tool_name -> 'session'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_approved_for_session(session: dict, tool_name: str) -> bool:
|
|
114
|
+
"""Check if tool was approved for this session.
|
|
115
|
+
|
|
116
|
+
Returns True if user previously approved this tool with scope='session'.
|
|
117
|
+
"""
|
|
118
|
+
approval = session.get('approval', {})
|
|
119
|
+
return approval.get('approved_tools', {}).get(tool_name) == 'session'
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _save_session_approval(session: dict, tool_name: str) -> None:
|
|
123
|
+
"""Save tool as approved for this session.
|
|
124
|
+
|
|
125
|
+
Future calls to the same tool will skip approval prompts.
|
|
126
|
+
"""
|
|
127
|
+
_init_approval_state(session)
|
|
128
|
+
session['approval']['approved_tools'][tool_name] = 'session'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _log(agent: 'Agent', message: str, style: str = None) -> None:
|
|
132
|
+
"""Log message via agent's logger if available.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
agent: Agent instance
|
|
136
|
+
message: Message to log
|
|
137
|
+
style: Rich style string (e.g., "[green]", "[red]")
|
|
138
|
+
"""
|
|
139
|
+
if hasattr(agent, 'logger') and agent.logger:
|
|
140
|
+
agent.logger.print(message, style)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@before_each_tool
|
|
144
|
+
def check_approval(agent: 'Agent') -> None:
|
|
145
|
+
"""Check if tool needs approval and request from client.
|
|
146
|
+
|
|
147
|
+
Flow:
|
|
148
|
+
1. Skip if no IO (not web mode)
|
|
149
|
+
2. Skip if safe tool
|
|
150
|
+
3. Skip if unknown tool (default: safe)
|
|
151
|
+
4. Skip if already approved for session
|
|
152
|
+
5. Send approval_needed, wait for response
|
|
153
|
+
6. If approved: optionally save to session, continue
|
|
154
|
+
7. If rejected: raise ValueError (stops batch)
|
|
155
|
+
|
|
156
|
+
Logging:
|
|
157
|
+
- Logs approval requests, approvals, and rejections
|
|
158
|
+
- Uses agent.logger.print() for terminal output
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: If user rejects the tool (includes feedback if provided)
|
|
162
|
+
"""
|
|
163
|
+
# No IO = not web mode, skip
|
|
164
|
+
if not agent.io:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Get pending tool info
|
|
168
|
+
pending = agent.current_session.get('pending_tool')
|
|
169
|
+
if not pending:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
tool_name = pending['name']
|
|
173
|
+
tool_args = pending['arguments']
|
|
174
|
+
|
|
175
|
+
# Safe tools don't need approval
|
|
176
|
+
if tool_name in SAFE_TOOLS:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Unknown tools (not in SAFE or DANGEROUS) are treated as safe
|
|
180
|
+
if tool_name not in DANGEROUS_TOOLS:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# Already approved for this session
|
|
184
|
+
if _is_approved_for_session(agent.current_session, tool_name):
|
|
185
|
+
_log(agent, f"[dim]⏭ {tool_name} (session-approved)[/dim]")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Send approval request to client
|
|
189
|
+
agent.io.send({
|
|
190
|
+
'type': 'approval_needed',
|
|
191
|
+
'tool': tool_name,
|
|
192
|
+
'arguments': tool_args,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# Wait for client response (BLOCKS)
|
|
196
|
+
response = agent.io.receive()
|
|
197
|
+
|
|
198
|
+
# Handle connection closed
|
|
199
|
+
if response.get('type') == 'io_closed':
|
|
200
|
+
_log(agent, f"[red]✗ {tool_name} - connection closed[/red]")
|
|
201
|
+
raise ValueError(f"Connection closed while waiting for approval of '{tool_name}'")
|
|
202
|
+
|
|
203
|
+
# Check approval
|
|
204
|
+
approved = response.get('approved', False)
|
|
205
|
+
|
|
206
|
+
if approved:
|
|
207
|
+
# Save to session if scope is "session"
|
|
208
|
+
scope = response.get('scope', 'once')
|
|
209
|
+
if scope == 'session':
|
|
210
|
+
_save_session_approval(agent.current_session, tool_name)
|
|
211
|
+
_log(agent, f"[green]✓ {tool_name} approved (session)[/green]")
|
|
212
|
+
else:
|
|
213
|
+
_log(agent, f"[green]✓ {tool_name} approved (once)[/green]")
|
|
214
|
+
# Continue to execute tool
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# Rejected - raise ValueError to stop batch
|
|
218
|
+
feedback = response.get('feedback', '')
|
|
219
|
+
if feedback:
|
|
220
|
+
_log(agent, f"[red]✗ {tool_name} rejected: {feedback}[/red]")
|
|
221
|
+
else:
|
|
222
|
+
_log(agent, f"[red]✗ {tool_name} rejected[/red]")
|
|
223
|
+
|
|
224
|
+
error_msg = f"User rejected tool '{tool_name}'."
|
|
225
|
+
if feedback:
|
|
226
|
+
error_msg += f" Feedback: {feedback}"
|
|
227
|
+
raise ValueError(error_msg)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Export as plugin (list of event handlers)
|
|
231
|
+
# Usage: Agent("name", plugins=[tool_approval])
|
|
232
|
+
# The plugin registers check_approval as a before_each_tool handler
|
|
233
|
+
tool_approval = [check_approval]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: connectonion
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.5
|
|
4
4
|
Summary: A simple Python framework for creating AI agents with behavior tracking
|
|
5
5
|
Project-URL: Homepage, https://github.com/openonion/connectonion
|
|
6
6
|
Project-URL: Documentation, https://docs.connectonion.com
|