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.
Files changed (50) hide show
  1. connectonion/__init__.py +1 -1
  2. connectonion/cli/co_ai/agent.py +3 -3
  3. connectonion/cli/co_ai/main.py +2 -2
  4. connectonion/cli/co_ai/plugins/__init__.py +2 -3
  5. connectonion/cli/co_ai/plugins/system_reminder.py +154 -0
  6. connectonion/cli/co_ai/prompts/connectonion/concepts/trust.md +166 -208
  7. connectonion/cli/co_ai/prompts/system-reminders/agent.md +23 -0
  8. connectonion/cli/co_ai/prompts/system-reminders/plan_mode.md +13 -0
  9. connectonion/cli/co_ai/prompts/system-reminders/security.md +14 -0
  10. connectonion/cli/co_ai/prompts/system-reminders/simplicity.md +14 -0
  11. connectonion/cli/co_ai/tools/plan_mode.py +1 -4
  12. connectonion/cli/co_ai/tools/read.py +0 -6
  13. connectonion/cli/commands/copy_commands.py +21 -0
  14. connectonion/cli/commands/trust_commands.py +152 -0
  15. connectonion/cli/main.py +82 -0
  16. connectonion/core/llm.py +2 -2
  17. connectonion/docs/concepts/fast_rules.md +237 -0
  18. connectonion/docs/concepts/onboarding.md +465 -0
  19. connectonion/docs/concepts/plugins.md +2 -1
  20. connectonion/docs/concepts/trust.md +933 -192
  21. connectonion/docs/design-decisions/023-trust-policy-system-design.md +323 -0
  22. connectonion/docs/network/README.md +23 -1
  23. connectonion/docs/network/connect.md +135 -0
  24. connectonion/docs/network/host.md +73 -4
  25. connectonion/docs/useful_plugins/tool_approval.md +139 -0
  26. connectonion/network/__init__.py +7 -6
  27. connectonion/network/asgi/__init__.py +3 -0
  28. connectonion/network/asgi/http.py +125 -19
  29. connectonion/network/asgi/websocket.py +276 -15
  30. connectonion/network/connect.py +145 -29
  31. connectonion/network/host/auth.py +70 -67
  32. connectonion/network/host/routes.py +88 -3
  33. connectonion/network/host/server.py +100 -17
  34. connectonion/network/trust/__init__.py +27 -19
  35. connectonion/network/trust/factory.py +51 -24
  36. connectonion/network/trust/fast_rules.py +100 -0
  37. connectonion/network/trust/tools.py +316 -32
  38. connectonion/network/trust/trust_agent.py +403 -0
  39. connectonion/transcribe.py +1 -1
  40. connectonion/useful_plugins/__init__.py +2 -1
  41. connectonion/useful_plugins/tool_approval.py +233 -0
  42. {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/METADATA +1 -1
  43. {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/RECORD +45 -37
  44. connectonion/cli/co_ai/plugins/reminder.py +0 -76
  45. connectonion/cli/co_ai/plugins/shell_approval.py +0 -105
  46. connectonion/cli/co_ai/prompts/reminders/plan_mode.md +0 -34
  47. connectonion/cli/co_ai/reminders.py +0 -159
  48. connectonion/network/trust/prompts.py +0 -71
  49. {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/WHEEL +0 -0
  50. {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
@@ -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() / ".connectonion" / ".co" / "config.toml"
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
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