patchpal 0.22.1__tar.gz → 0.22.2__tar.gz

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 (67) hide show
  1. {patchpal-0.22.1/patchpal.egg-info → patchpal-0.22.2}/PKG-INFO +1 -1
  2. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/__init__.py +1 -1
  3. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/agent/function_calling.py +8 -0
  4. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/agent/react.py +8 -0
  5. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/autopilot.py +36 -0
  6. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/interactive.py +37 -6
  7. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/permissions.py +57 -6
  8. patchpal-0.22.2/patchpal/tools/audit.py +405 -0
  9. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/code_analysis.py +0 -1
  10. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/common.py +32 -3
  11. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/file_reading.py +57 -17
  12. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/file_writing.py +0 -8
  13. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/find_tool.py +0 -5
  14. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/grep_tool.py +0 -3
  15. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/repo_map.py +19 -11
  16. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/shell_tools.py +45 -12
  17. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/todo_tools.py +0 -9
  18. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/user_interaction.py +18 -5
  19. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/web_tools.py +31 -4
  20. {patchpal-0.22.1 → patchpal-0.22.2/patchpal.egg-info}/PKG-INFO +1 -1
  21. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/SOURCES.txt +1 -0
  22. {patchpal-0.22.1 → patchpal-0.22.2}/LICENSE +0 -0
  23. {patchpal-0.22.1 → patchpal-0.22.2}/MANIFEST.in +0 -0
  24. {patchpal-0.22.1 → patchpal-0.22.2}/README.md +0 -0
  25. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/agent/__init__.py +0 -0
  26. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/__init__.py +0 -0
  27. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/mcp.py +0 -0
  28. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/sandbox.py +0 -0
  29. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/streaming.py +0 -0
  30. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/config.py +0 -0
  31. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/context.py +0 -0
  32. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/prompts/react_prompt.md +0 -0
  33. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/prompts/system_prompt.md +0 -0
  34. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/skills.py +0 -0
  35. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/__init__.py +0 -0
  36. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/definitions.py +0 -0
  37. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/image_handler.py +0 -0
  38. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/mcp.py +0 -0
  39. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/tool_schema.py +0 -0
  40. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/dependency_links.txt +0 -0
  41. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/entry_points.txt +0 -0
  42. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/requires.txt +0 -0
  43. {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/top_level.txt +0 -0
  44. {patchpal-0.22.1 → patchpal-0.22.2}/pyproject.toml +0 -0
  45. {patchpal-0.22.1 → patchpal-0.22.2}/setup.cfg +0 -0
  46. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_agent.py +0 -0
  47. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_cli.py +0 -0
  48. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_config_dynamic.py +0 -0
  49. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_context.py +0 -0
  50. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_custom_tools.py +0 -0
  51. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_enabled_tools.py +0 -0
  52. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_find_tool.py +0 -0
  53. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_guardrails.py +0 -0
  54. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_image_blocking.py +0 -0
  55. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_maximum_security.py +0 -0
  56. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_mcp_config.py +0 -0
  57. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_memory.py +0 -0
  58. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_operational_safety.py +0 -0
  59. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_optional_tools.py +0 -0
  60. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_permissions.py +0 -0
  61. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_react.py +0 -0
  62. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_reasoning_content.py +0 -0
  63. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_repo_map.py +0 -0
  64. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_simplified_prompt.py +0 -0
  65. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_skills.py +0 -0
  66. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_streaming.py +0 -0
  67. {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.22.1
3
+ Version: 0.22.2
4
4
  Summary: An agentic coding and automation assistant, supporting both local and cloud LLMs
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.22.1"
3
+ __version__ = "0.22.2"
4
4
 
5
5
  from patchpal.agent import create_agent, create_react_agent
6
6
  from patchpal.cli.autopilot import autopilot_loop
@@ -508,6 +508,14 @@ class PatchPalAgent:
508
508
  # Load MEMORY.md if it exists and has non-template content
509
509
  self._load_project_memory()
510
510
 
511
+ # Log session start
512
+ try:
513
+ from patchpal.tools.audit import log_session_start
514
+
515
+ log_session_start(agent_type="function_calling", model=self.model_id)
516
+ except Exception:
517
+ pass # Don't fail if audit logging fails
518
+
511
519
  def _load_project_memory(self):
512
520
  """Load MEMORY.md file at session start if it has non-template content."""
513
521
  try:
@@ -164,6 +164,14 @@ class ReActAgent:
164
164
  # Load project memory
165
165
  self._load_project_memory()
166
166
 
167
+ # Log session start
168
+ try:
169
+ from patchpal.tools.audit import log_session_start
170
+
171
+ log_session_start(agent_type="react", model=self.model_id)
172
+ except Exception:
173
+ pass # Don't fail if audit logging fails
174
+
167
175
  def _load_project_memory(self):
168
176
  """Load project memory file if it exists."""
169
177
  from pathlib import Path
@@ -112,10 +112,27 @@ def autopilot_loop(
112
112
  print(f"🔄 Autopilot Iteration {iteration + 1}/{max_iterations}")
113
113
  print(f"{'=' * 80}\n")
114
114
 
115
+ # Log user prompt to audit log (first iteration only)
116
+ if iteration == 0:
117
+ try:
118
+ from patchpal.tools.audit import log_user_prompt
119
+
120
+ log_user_prompt(prompt)
121
+ except Exception:
122
+ pass # Don't fail if audit logging fails
123
+
115
124
  # Run agent with the SAME prompt every time
116
125
  # The agent's conversation history accumulates, so it can see all previous work
117
126
  response = agent.run(prompt, max_iterations=100)
118
127
 
128
+ # Log agent response to audit log
129
+ try:
130
+ from patchpal.tools.audit import log_agent_response
131
+
132
+ log_agent_response(response, success=True)
133
+ except Exception:
134
+ pass # Don't fail if audit logging fails
135
+
119
136
  print(f"\n{'=' * 80}")
120
137
  print("📝 Agent Response:")
121
138
  print(f"{'=' * 80}")
@@ -144,6 +161,16 @@ def autopilot_loop(
144
161
  )
145
162
  if agent.cumulative_cost > 0:
146
163
  print(f"Total cost: ${agent.cumulative_cost:.4f}")
164
+
165
+ # Log successful session end
166
+ try:
167
+ from patchpal.tools.audit import log_session_end
168
+ from patchpal.tools.common import get_operation_count
169
+
170
+ log_session_end(total_operations=get_operation_count(), success=True)
171
+ except Exception:
172
+ pass # Don't fail if audit logging fails
173
+
147
174
  return response
148
175
 
149
176
  # Stop hook: Agent tried to complete, but no completion promise
@@ -168,6 +195,15 @@ def autopilot_loop(
168
195
  if agent.cumulative_cost > 0:
169
196
  print(f"Total cost: ${agent.cumulative_cost:.4f}")
170
197
 
198
+ # Log session end
199
+ try:
200
+ from patchpal.tools.audit import log_session_end
201
+ from patchpal.tools.common import get_operation_count
202
+
203
+ log_session_end(total_operations=get_operation_count(), success=False)
204
+ except Exception:
205
+ pass # Don't fail if audit logging fails
206
+
171
207
  return None
172
208
 
173
209
 
@@ -564,6 +564,15 @@ Supported models: Any LiteLLM-supported model
564
564
 
565
565
  audit_logger.info(", ".join(log_parts))
566
566
 
567
+ # Log structured session end
568
+ try:
569
+ from patchpal.tools.audit import log_session_end
570
+ from patchpal.tools.common import get_operation_count
571
+
572
+ log_session_end(total_operations=get_operation_count(), success=True)
573
+ except Exception:
574
+ pass # Don't fail if audit logging fails
575
+
567
576
  print("\nGoodbye!")
568
577
  break
569
578
 
@@ -1542,10 +1551,17 @@ Supported models: Any LiteLLM-supported model
1542
1551
  if skill_args:
1543
1552
  prompt += f"\n\nArguments: {skill_args}"
1544
1553
 
1545
- # Log user prompt to audit log (sanitize to prevent Windows Unicode errors)
1546
- audit_logger.info(
1547
- _sanitize_for_logging(f"USER_PROMPT: /{skill_name} {skill_args}")
1548
- )
1554
+ # Log skill invocation to audit log with hash-chaining
1555
+ try:
1556
+ from patchpal.tools.audit import log_user_prompt
1557
+
1558
+ log_user_prompt(f"/{skill_name} {skill_args}")
1559
+ except Exception:
1560
+ # Fallback to old-style logging if audit fails
1561
+ audit_logger.info(
1562
+ _sanitize_for_logging(f"USER_PROMPT: /{skill_name} {skill_args}")
1563
+ )
1564
+
1549
1565
  result = agent.run(prompt, max_iterations=max_iterations)
1550
1566
 
1551
1567
  print("\n" + "=" * 80)
@@ -1565,10 +1581,25 @@ Supported models: Any LiteLLM-supported model
1565
1581
  # Run the agent (Ctrl-C here will interrupt agent, not exit)
1566
1582
  try:
1567
1583
  print() # Add blank line before agent output
1568
- # Log user prompt to audit log (sanitize to prevent Windows Unicode errors)
1569
- audit_logger.info(_sanitize_for_logging(f"USER_PROMPT: {user_input}"))
1584
+ # Log user prompt to audit log with hash-chaining
1585
+ try:
1586
+ from patchpal.tools.audit import log_user_prompt
1587
+
1588
+ log_user_prompt(user_input)
1589
+ except Exception:
1590
+ # Fallback to old-style logging if audit fails
1591
+ audit_logger.info(_sanitize_for_logging(f"USER_PROMPT: {user_input}"))
1592
+
1570
1593
  result = agent.run(user_input, max_iterations=max_iterations)
1571
1594
 
1595
+ # Log agent response to audit log with hash-chaining
1596
+ try:
1597
+ from patchpal.tools.audit import log_agent_response
1598
+
1599
+ log_agent_response(result, success=True)
1600
+ except Exception:
1601
+ pass # Don't fail if audit logging fails
1602
+
1572
1603
  print("\n" + "=" * 80)
1573
1604
  print("\033[1;32mAgent:\033[0m")
1574
1605
  print("=" * 80)
@@ -67,6 +67,11 @@ class PermissionManager:
67
67
  These commands replace dedicated tools that were removed (replaced by find tool)
68
68
  to reduce redundancy. Since those tools didn't require permissions, their shell
69
69
  equivalents shouldn't either.
70
+
71
+ SECURITY NOTE: Environment variable commands (env, printenv, set, Get-Variable)
72
+ are NOT in this list because they can expose API keys and secrets loaded from
73
+ .env files. While we block reading .env files directly, we must also block
74
+ reading the environment variables that were loaded from them.
70
75
  """
71
76
  # Check if web tools are enabled
72
77
  web_tools_enabled = config.ENABLE_WEB
@@ -99,9 +104,6 @@ class PermissionManager:
99
104
  "whereis",
100
105
  # Current directory
101
106
  "pwd",
102
- # Environment
103
- "env",
104
- "printenv",
105
107
  # Network diagnostic
106
108
  "ifconfig",
107
109
  # Disk/system info
@@ -132,8 +134,6 @@ class PermissionManager:
132
134
  "assoc",
133
135
  "ftype",
134
136
  "doskey /history",
135
- # Environment
136
- "set",
137
137
  # Network diagnostic
138
138
  "tracert",
139
139
  "nslookup",
@@ -169,7 +169,6 @@ class PermissionManager:
169
169
  "get-host",
170
170
  "get-command",
171
171
  "get-alias",
172
- "get-variable",
173
172
  "get-member",
174
173
  "get-help",
175
174
  # Search/filter
@@ -420,6 +419,19 @@ class PermissionManager:
420
419
 
421
420
  # Check if already granted (with full_command for multi-word pattern matching)
422
421
  if self._check_existing_grant(tool_name, pattern, full_command):
422
+ # Log that permission was auto-granted from previous session grant
423
+ try:
424
+ from patchpal.tools.audit import log_action_approved
425
+
426
+ log_action_approved(
427
+ tool_name=tool_name,
428
+ description=description,
429
+ approval_type="auto_granted",
430
+ pattern=pattern,
431
+ context={"working_dir": context} if context else None,
432
+ )
433
+ except Exception:
434
+ pass # Don't fail if audit logging fails
423
435
  return True
424
436
 
425
437
  # Display the request - use stderr to avoid Rich console capture
@@ -481,14 +493,53 @@ class PermissionManager:
481
493
  choice = input("\n\033[1;36mChoice [1-3]:\033[0m ").strip()
482
494
 
483
495
  if choice == "1":
496
+ # Log approval
497
+ try:
498
+ from patchpal.tools.audit import log_action_approved
499
+
500
+ log_action_approved(
501
+ tool_name=tool_name,
502
+ description=description,
503
+ approval_type="user_approved",
504
+ pattern=pattern,
505
+ context={"working_dir": context} if context else None,
506
+ )
507
+ except Exception:
508
+ pass # Don't fail if audit logging fails
484
509
  return True
485
510
  elif choice == "2":
486
511
  # Grant session-only permission (like Claude Code)
487
512
  self._grant_permission(tool_name, persistent=False, pattern=pattern)
513
+ # Log approval with session grant
514
+ try:
515
+ from patchpal.tools.audit import log_action_approved
516
+
517
+ log_action_approved(
518
+ tool_name=tool_name,
519
+ description=description,
520
+ approval_type="session_granted",
521
+ pattern=pattern,
522
+ context={"working_dir": context} if context else None,
523
+ )
524
+ except Exception:
525
+ pass # Don't fail if audit logging fails
488
526
  return True
489
527
  elif choice == "3":
490
528
  sys.stderr.write("\n\033[1;31mOperation cancelled.\033[0m\n")
491
529
  sys.stderr.flush()
530
+ # Log rejection
531
+ try:
532
+ from patchpal.tools.audit import log_action_blocked
533
+
534
+ log_action_blocked(
535
+ tool_name=tool_name,
536
+ description=description,
537
+ reason="user_rejected",
538
+ pattern=pattern,
539
+ context={"working_dir": context} if context else None,
540
+ )
541
+ except Exception:
542
+ pass # Don't fail if audit logging fails
492
543
  return False
493
544
  else:
494
545
  sys.stderr.write("Invalid choice. Please enter 1, 2, or 3.\n")
@@ -0,0 +1,405 @@
1
+ """Enhanced audit logging for compliance with enterprise logging requirements.
2
+
3
+ This module provides structured JSON-based audit logging with:
4
+ - Unique session identifiers
5
+ - User identity tracking
6
+ - Action approval/rejection logging
7
+ - Structured format for easy parsing
8
+ - Tamper-evidence via cryptographic hash-chaining (SHA-256)
9
+
10
+ Each log entry contains:
11
+ - A hash of its own contents
12
+ - The hash of the previous entry (creating an immutable chain)
13
+
14
+ This makes logs tamper-evident: modifying any entry breaks the chain.
15
+ """
16
+
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import uuid
21
+ from datetime import datetime
22
+ from typing import Optional
23
+
24
+ from patchpal.config import config
25
+
26
+ # Session ID - generated once per agent session
27
+ _session_id: Optional[str] = None
28
+
29
+ # Previous entry hash for chain verification
30
+ _prev_hash: Optional[str] = None
31
+
32
+
33
+ def _compute_hash(entry: dict) -> str:
34
+ """Compute SHA-256 hash of a log entry.
35
+
36
+ Args:
37
+ entry: Log entry dictionary (without hash field)
38
+
39
+ Returns:
40
+ Hex-encoded SHA-256 hash
41
+ """
42
+ # Create canonical JSON (sorted keys, no whitespace)
43
+ canonical = json.dumps(entry, sort_keys=True, separators=(",", ":"))
44
+
45
+ # Compute SHA-256 hash
46
+ hash_obj = hashlib.sha256(canonical.encode("utf-8"))
47
+
48
+ return hash_obj.hexdigest()
49
+
50
+
51
+ def _log_entry(entry: dict):
52
+ """Log an entry with hash-chaining.
53
+
54
+ This centralizes the hash-chaining logic:
55
+ - Adds prev_hash from previous entry
56
+ - Computes hash of this entry
57
+ - Updates prev_hash for next entry
58
+ - Logs as JSON
59
+
60
+ Args:
61
+ entry: Log entry dictionary (without prev_hash/hash fields)
62
+ """
63
+ if not config.AUDIT_LOG:
64
+ return
65
+
66
+ from patchpal.tools.common import audit_logger
67
+
68
+ # Add previous hash for chain
69
+ global _prev_hash
70
+ if _prev_hash is not None:
71
+ entry["prev_hash"] = _prev_hash
72
+
73
+ # Compute hash of this entry
74
+ entry_hash = _compute_hash(entry)
75
+ entry["hash"] = entry_hash
76
+
77
+ # Update prev_hash for next entry
78
+ _prev_hash = entry_hash
79
+
80
+ # Log as JSON
81
+ audit_logger.info(json.dumps(entry))
82
+
83
+
84
+ def verify_hash_chain(entries: list[dict]) -> tuple[bool, Optional[str]]:
85
+ """Verify the hash chain of log entries.
86
+
87
+ Args:
88
+ entries: List of log entry dictionaries (must include 'hash' and 'prev_hash' fields)
89
+
90
+ Returns:
91
+ Tuple of (is_valid, error_message)
92
+ - (True, None) if chain is valid
93
+ - (False, error_message) if chain is broken
94
+ """
95
+ if not entries:
96
+ return True, None
97
+
98
+ prev_hash = None
99
+
100
+ for i, entry in enumerate(entries):
101
+ # Check if entry has required hash field
102
+ if "hash" not in entry:
103
+ return False, f"Entry {i} missing 'hash' field"
104
+
105
+ # Extract hash and prev_hash
106
+ stored_hash = entry.pop("hash")
107
+ stored_prev_hash = entry.get("prev_hash")
108
+
109
+ # First entry should have no prev_hash (SESSION_START)
110
+ if i == 0:
111
+ if stored_prev_hash is not None:
112
+ entry["hash"] = stored_hash # Restore
113
+ return False, "Entry 0 (SESSION_START) should not have 'prev_hash'"
114
+ else:
115
+ # Subsequent entries must have prev_hash matching previous entry
116
+ if stored_prev_hash != prev_hash:
117
+ entry["hash"] = stored_hash # Restore
118
+ return (
119
+ False,
120
+ f"Entry {i}: prev_hash mismatch (expected {prev_hash[:8]}..., got {stored_prev_hash[:8] if stored_prev_hash else None}...)",
121
+ )
122
+
123
+ # Compute expected hash
124
+ expected_hash = _compute_hash(entry)
125
+
126
+ # Restore hash to entry
127
+ entry["hash"] = stored_hash
128
+
129
+ # Verify hash matches
130
+ if stored_hash != expected_hash:
131
+ return (
132
+ False,
133
+ f"Entry {i}: hash mismatch (expected {expected_hash[:8]}..., got {stored_hash[:8]}...)",
134
+ )
135
+
136
+ # Update prev_hash for next iteration
137
+ prev_hash = stored_hash
138
+
139
+ return True, None
140
+
141
+
142
+ def reset_prev_hash():
143
+ """Reset previous hash (for testing or new sessions)."""
144
+ global _prev_hash
145
+ _prev_hash = None
146
+
147
+
148
+ def get_session_id() -> str:
149
+ """Get or create session ID for this agent run.
150
+
151
+ Returns:
152
+ UUID string identifying this session
153
+ """
154
+ global _session_id
155
+ if _session_id is None:
156
+ _session_id = str(uuid.uuid4())
157
+ return _session_id
158
+
159
+
160
+ def reset_session_id():
161
+ """Reset session ID (for testing or new sessions)."""
162
+ global _session_id
163
+ _session_id = None
164
+
165
+
166
+ def get_user_identity() -> str:
167
+ """Get user identity from environment.
168
+
169
+ Returns:
170
+ Username from environment or 'unknown'
171
+ """
172
+ # Try multiple environment variables for cross-platform compatibility
173
+ return (
174
+ os.environ.get("USER")
175
+ or os.environ.get("USERNAME")
176
+ or os.environ.get("LOGNAME")
177
+ or "unknown"
178
+ )
179
+
180
+
181
+ def log_action_blocked(
182
+ tool_name: str,
183
+ description: str,
184
+ reason: str,
185
+ pattern: Optional[str] = None,
186
+ context: Optional[dict] = None,
187
+ ):
188
+ """Log a blocked/rejected action.
189
+
190
+ Args:
191
+ tool_name: Name of the tool (e.g., 'run_shell', 'write_file')
192
+ description: Human-readable description of the action
193
+ reason: Why the action was blocked (e.g., 'user_rejected', 'dangerous_command', 'sensitive_file')
194
+ pattern: Optional pattern that was blocked (e.g., command pattern, file path)
195
+ context: Optional additional context dictionary
196
+ """
197
+ entry = {
198
+ "timestamp": datetime.utcnow().isoformat() + "Z",
199
+ "session_id": get_session_id(),
200
+ "user": get_user_identity(),
201
+ "event_type": "ACTION_BLOCKED",
202
+ "tool_name": tool_name,
203
+ "description": description,
204
+ "reason": reason,
205
+ "outcome": "rejected",
206
+ }
207
+
208
+ if pattern:
209
+ entry["pattern"] = pattern
210
+
211
+ if context:
212
+ entry["context"] = context
213
+
214
+ _log_entry(entry)
215
+
216
+
217
+ def log_action_approved(
218
+ tool_name: str,
219
+ description: str,
220
+ approval_type: str = "user_approved",
221
+ pattern: Optional[str] = None,
222
+ context: Optional[dict] = None,
223
+ ):
224
+ """Log an approved action.
225
+
226
+ Args:
227
+ tool_name: Name of the tool
228
+ description: Human-readable description of the action
229
+ approval_type: Type of approval (e.g., 'user_approved', 'session_granted', 'auto_granted')
230
+ pattern: Optional pattern that was approved
231
+ context: Optional additional context dictionary
232
+ """
233
+ entry = {
234
+ "timestamp": datetime.utcnow().isoformat() + "Z",
235
+ "session_id": get_session_id(),
236
+ "user": get_user_identity(),
237
+ "event_type": "ACTION_APPROVED",
238
+ "tool_name": tool_name,
239
+ "description": description,
240
+ "approval_type": approval_type,
241
+ "outcome": "approved",
242
+ }
243
+
244
+ if pattern:
245
+ entry["pattern"] = pattern
246
+
247
+ if context:
248
+ entry["context"] = context
249
+
250
+ _log_entry(entry)
251
+
252
+
253
+ def log_action_result(
254
+ tool_name: str,
255
+ description: str,
256
+ success: bool,
257
+ error: Optional[str] = None,
258
+ context: Optional[dict] = None,
259
+ ):
260
+ """Log the result of an action execution.
261
+
262
+ Args:
263
+ tool_name: Name of the tool
264
+ description: Human-readable description of the action
265
+ success: Whether the action succeeded
266
+ error: Optional error message if action failed
267
+ context: Optional additional context dictionary
268
+ """
269
+ entry = {
270
+ "timestamp": datetime.utcnow().isoformat() + "Z",
271
+ "session_id": get_session_id(),
272
+ "user": get_user_identity(),
273
+ "event_type": "ACTION_RESULT",
274
+ "tool_name": tool_name,
275
+ "description": description,
276
+ "outcome": "success" if success else "error",
277
+ }
278
+
279
+ if error:
280
+ entry["error"] = error
281
+
282
+ if context:
283
+ entry["context"] = context
284
+
285
+ _log_entry(entry)
286
+
287
+
288
+ def log_session_start(agent_type: str = "function_calling", model: str = "unknown"):
289
+ """Log the start of a new agent session.
290
+
291
+ Args:
292
+ agent_type: Type of agent (e.g., 'function_calling', 'react')
293
+ model: Model identifier being used
294
+ """
295
+ # Reset chain for new session
296
+ global _prev_hash
297
+ _prev_hash = None
298
+
299
+ entry = {
300
+ "timestamp": datetime.utcnow().isoformat() + "Z",
301
+ "session_id": get_session_id(),
302
+ "user": get_user_identity(),
303
+ "event_type": "SESSION_START",
304
+ "agent_type": agent_type,
305
+ "model": model,
306
+ }
307
+
308
+ _log_entry(entry)
309
+
310
+
311
+ def log_session_end(total_operations: int = 0, success: bool = True):
312
+ """Log the end of an agent session.
313
+
314
+ Args:
315
+ total_operations: Total number of operations performed
316
+ success: Whether the session completed successfully
317
+ """
318
+ entry = {
319
+ "timestamp": datetime.utcnow().isoformat() + "Z",
320
+ "session_id": get_session_id(),
321
+ "user": get_user_identity(),
322
+ "event_type": "SESSION_END",
323
+ "total_operations": total_operations,
324
+ "outcome": "success" if success else "error",
325
+ }
326
+
327
+ _log_entry(entry)
328
+
329
+
330
+ def log_user_prompt(prompt: str):
331
+ """Log a user prompt/message.
332
+
333
+ Args:
334
+ prompt: The user's prompt/request
335
+ """
336
+ # Truncate very long prompts for logging
337
+ max_length = 1000
338
+ if len(prompt) > max_length:
339
+ prompt = prompt[:max_length] + "... (truncated)"
340
+
341
+ entry = {
342
+ "timestamp": datetime.utcnow().isoformat() + "Z",
343
+ "session_id": get_session_id(),
344
+ "user": get_user_identity(),
345
+ "event_type": "USER_PROMPT",
346
+ "prompt": prompt,
347
+ }
348
+
349
+ _log_entry(entry)
350
+
351
+
352
+ def log_agent_response(response: str, success: bool = True):
353
+ """Log an agent response.
354
+
355
+ Args:
356
+ response: The agent's response
357
+ success: Whether the response was successful
358
+ """
359
+ # Truncate very long responses for logging
360
+ max_length = 1000
361
+ if len(response) > max_length:
362
+ response = response[:max_length] + "... (truncated)"
363
+
364
+ entry = {
365
+ "timestamp": datetime.utcnow().isoformat() + "Z",
366
+ "session_id": get_session_id(),
367
+ "user": get_user_identity(),
368
+ "event_type": "AGENT_RESPONSE",
369
+ "response": response,
370
+ "outcome": "success" if success else "error",
371
+ }
372
+
373
+ _log_entry(entry)
374
+
375
+
376
+ def log_tool_execution(tool_name: str, parameters: dict = None, operation_num: int = None):
377
+ """Log a tool execution.
378
+
379
+ Args:
380
+ tool_name: Name of the tool (e.g., 'run_shell', 'read_file')
381
+ parameters: Optional dict of parameters passed to the tool
382
+ operation_num: Optional operation number
383
+ """
384
+ entry = {
385
+ "timestamp": datetime.utcnow().isoformat() + "Z",
386
+ "session_id": get_session_id(),
387
+ "user": get_user_identity(),
388
+ "event_type": "TOOL_EXECUTION",
389
+ "tool_name": tool_name,
390
+ }
391
+
392
+ if parameters:
393
+ # Truncate large parameter values
394
+ truncated_params = {}
395
+ for key, value in parameters.items():
396
+ if isinstance(value, str) and len(value) > 200:
397
+ truncated_params[key] = value[:200] + "... (truncated)"
398
+ else:
399
+ truncated_params[key] = value
400
+ entry["parameters"] = truncated_params
401
+
402
+ if operation_num is not None:
403
+ entry["operation_num"] = operation_num
404
+
405
+ _log_entry(entry)