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.
- {patchpal-0.22.1/patchpal.egg-info → patchpal-0.22.2}/PKG-INFO +1 -1
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/__init__.py +1 -1
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/agent/function_calling.py +8 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/agent/react.py +8 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/autopilot.py +36 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/interactive.py +37 -6
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/permissions.py +57 -6
- patchpal-0.22.2/patchpal/tools/audit.py +405 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/code_analysis.py +0 -1
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/common.py +32 -3
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/file_reading.py +57 -17
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/file_writing.py +0 -8
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/find_tool.py +0 -5
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/grep_tool.py +0 -3
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/repo_map.py +19 -11
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/shell_tools.py +45 -12
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/todo_tools.py +0 -9
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/user_interaction.py +18 -5
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/web_tools.py +31 -4
- {patchpal-0.22.1 → patchpal-0.22.2/patchpal.egg-info}/PKG-INFO +1 -1
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/SOURCES.txt +1 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/LICENSE +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/MANIFEST.in +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/README.md +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/agent/__init__.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/__init__.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/mcp.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/sandbox.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/cli/streaming.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/config.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/context.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/prompts/react_prompt.md +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/prompts/system_prompt.md +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/skills.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/__init__.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/definitions.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/image_handler.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/mcp.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal/tools/tool_schema.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/dependency_links.txt +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/entry_points.txt +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/requires.txt +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/patchpal.egg-info/top_level.txt +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/pyproject.toml +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/setup.cfg +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_agent.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_cli.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_config_dynamic.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_context.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_custom_tools.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_enabled_tools.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_find_tool.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_guardrails.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_image_blocking.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_maximum_security.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_mcp_config.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_memory.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_operational_safety.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_optional_tools.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_permissions.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_react.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_reasoning_content.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_repo_map.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_simplified_prompt.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_skills.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_streaming.py +0 -0
- {patchpal-0.22.1 → patchpal-0.22.2}/tests/test_tools.py +0 -0
|
@@ -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
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
|
1569
|
-
|
|
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)
|