@rulemetric/proxy 0.2.2
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.
- package/Dockerfile +28 -0
- package/addon/context_classifier.py +375 -0
- package/addon/providers.py +483 -0
- package/addon/reporter.py +778 -0
- package/addon/requirements.txt +1 -0
- package/addon/rulemetric_addon.py +1288 -0
- package/addon/secret_redactor.py +130 -0
- package/addon/security_scanner.py +828 -0
- package/addon/session_linker.py +206 -0
- package/addon/sse_parser.py +364 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/anthropic.d.ts +8 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +135 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/base.d.ts +11 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +43 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -0
- package/dist/providers/bedrock.js +125 -0
- package/dist/providers/bedrock.js.map +1 -0
- package/dist/providers/gemini.d.ts +9 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +140 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +116 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +129 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/session-linker.d.ts +6 -0
- package/dist/session-linker.d.ts.map +1 -0
- package/dist/session-linker.js +68 -0
- package/dist/session-linker.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -0
package/Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
# Install mitmproxy (pinned version for supply chain safety)
|
|
4
|
+
RUN pip install --no-cache-dir mitmproxy==11.0.2
|
|
5
|
+
|
|
6
|
+
# Copy addon scripts
|
|
7
|
+
WORKDIR /app
|
|
8
|
+
COPY addon/ ./addon/
|
|
9
|
+
|
|
10
|
+
# mitmproxy generates CA certs on first run — persist them via volume
|
|
11
|
+
VOLUME /root/.mitmproxy
|
|
12
|
+
|
|
13
|
+
# Proxy listens on 8787
|
|
14
|
+
EXPOSE 8787
|
|
15
|
+
|
|
16
|
+
# Environment (override at runtime)
|
|
17
|
+
ENV RULEMETRIC_API_URL=http://host.docker.internal:3000
|
|
18
|
+
ENV RULEMETRIC_PROJECT_PATH=""
|
|
19
|
+
ENV RULEMETRIC_API_KEY=""
|
|
20
|
+
ENV RULEMETRIC_ACCESS_TOKEN=""
|
|
21
|
+
|
|
22
|
+
# Only intercept known LLM provider domains (not all HTTPS traffic)
|
|
23
|
+
ENTRYPOINT ["mitmdump", \
|
|
24
|
+
"--listen-host", "0.0.0.0", \
|
|
25
|
+
"--listen-port", "8787", \
|
|
26
|
+
"--set", "console_eventlog_verbosity=info", \
|
|
27
|
+
"--allow-hosts", "api.anthropic.com|api.openai.com|generativelanguage.googleapis.com|bedrock-runtime.*.amazonaws.com", \
|
|
28
|
+
"-s", "/app/addon/rulemetric_addon.py"]
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Classify context items from an LLM API request snapshot.
|
|
2
|
+
|
|
3
|
+
Walks system prompt, messages_raw, and tools to produce a typed inventory
|
|
4
|
+
of every piece of injected context (CLAUDE.md, skills, memory, hooks, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _hash(text: str) -> str:
|
|
15
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _estimate_tokens(text: str) -> int:
|
|
19
|
+
return max(1, len(text) // 4)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Pattern matchers for system-reminder classification
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
# CLAUDE.md files: "Contents of /path/to/CLAUDE.md"
|
|
27
|
+
_CLAUDE_MD_RE = re.compile(
|
|
28
|
+
r"Contents of (.*?CLAUDE\.md)\s.*?instructions",
|
|
29
|
+
re.IGNORECASE | re.DOTALL,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Memory files: "Contents of /...memory/MEMORY.md" or individual memory files
|
|
33
|
+
_MEMORY_RE = re.compile(
|
|
34
|
+
r"Contents of (.*?/memory/\S+)",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Skill catalog: block that lists available skills with "- skill-name: description"
|
|
39
|
+
_SKILL_CATALOG_RE = re.compile(
|
|
40
|
+
r"following skills are available.*?:\s*\n",
|
|
41
|
+
re.IGNORECASE | re.DOTALL,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Individual skill entry in catalog: "- name: description"
|
|
45
|
+
_SKILL_ENTRY_RE = re.compile(r"^- ([\w:.-]+): .+", re.MULTILINE)
|
|
46
|
+
|
|
47
|
+
# Git status block
|
|
48
|
+
_GIT_STATUS_RE = re.compile(r"gitStatus:", re.IGNORECASE)
|
|
49
|
+
|
|
50
|
+
# Deferred tools listing
|
|
51
|
+
_DEFERRED_TOOLS_RE = re.compile(
|
|
52
|
+
r"<available-deferred-tools>",
|
|
53
|
+
re.IGNORECASE,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Date change reminder
|
|
57
|
+
_DATE_CHANGE_RE = re.compile(
|
|
58
|
+
r"The date has changed\. Today.s date is now",
|
|
59
|
+
re.IGNORECASE,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Task tools reminder
|
|
63
|
+
_TASK_REMINDER_RE = re.compile(
|
|
64
|
+
r"task tools haven.t been used recently",
|
|
65
|
+
re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# currentDate block
|
|
69
|
+
_CURRENT_DATE_RE = re.compile(r"# currentDate", re.IGNORECASE)
|
|
70
|
+
|
|
71
|
+
# Skill tool use: assistant calls Skill tool
|
|
72
|
+
_SKILL_TOOL_USE_RE = re.compile(r'"name"\s*:\s*"Skill"', re.IGNORECASE)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def classify_system_reminder(text: str) -> dict[str, Any]:
|
|
76
|
+
"""Classify a single <system-reminder> block by content pattern."""
|
|
77
|
+
item: dict[str, Any] = {
|
|
78
|
+
"type": "system_reminder",
|
|
79
|
+
"name": "unknown_reminder",
|
|
80
|
+
"content_hash": _hash(text),
|
|
81
|
+
"char_count": len(text),
|
|
82
|
+
"estimated_tokens": _estimate_tokens(text),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# CLAUDE.md
|
|
86
|
+
m = _CLAUDE_MD_RE.search(text)
|
|
87
|
+
if m:
|
|
88
|
+
item["type"] = "claude_md"
|
|
89
|
+
item["name"] = m.group(1).strip()
|
|
90
|
+
return item
|
|
91
|
+
|
|
92
|
+
# Memory
|
|
93
|
+
m = _MEMORY_RE.search(text)
|
|
94
|
+
if m:
|
|
95
|
+
item["type"] = "memory"
|
|
96
|
+
item["name"] = m.group(1).strip()
|
|
97
|
+
return item
|
|
98
|
+
|
|
99
|
+
# Skill catalog
|
|
100
|
+
if _SKILL_CATALOG_RE.search(text):
|
|
101
|
+
item["type"] = "skill_catalog"
|
|
102
|
+
item["name"] = "available_skills"
|
|
103
|
+
# Extract individual skill names
|
|
104
|
+
entries = _SKILL_ENTRY_RE.findall(text)
|
|
105
|
+
if entries:
|
|
106
|
+
item["skill_names"] = entries
|
|
107
|
+
return item
|
|
108
|
+
|
|
109
|
+
# Git status
|
|
110
|
+
if _GIT_STATUS_RE.search(text):
|
|
111
|
+
item["type"] = "git_context"
|
|
112
|
+
item["name"] = "git_status"
|
|
113
|
+
return item
|
|
114
|
+
|
|
115
|
+
# Date change
|
|
116
|
+
if _DATE_CHANGE_RE.search(text):
|
|
117
|
+
item["type"] = "hook_output"
|
|
118
|
+
item["name"] = "date_change"
|
|
119
|
+
return item
|
|
120
|
+
|
|
121
|
+
# Task reminder
|
|
122
|
+
if _TASK_REMINDER_RE.search(text):
|
|
123
|
+
item["type"] = "hook_output"
|
|
124
|
+
item["name"] = "task_reminder"
|
|
125
|
+
return item
|
|
126
|
+
|
|
127
|
+
# currentDate
|
|
128
|
+
if _CURRENT_DATE_RE.search(text):
|
|
129
|
+
item["type"] = "hook_output"
|
|
130
|
+
item["name"] = "current_date"
|
|
131
|
+
return item
|
|
132
|
+
|
|
133
|
+
return item
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def classify_deferred_tools(text: str) -> dict[str, Any] | None:
|
|
137
|
+
"""Check if text contains <available-deferred-tools> and classify it."""
|
|
138
|
+
if "<available-deferred-tools>" not in text:
|
|
139
|
+
return None
|
|
140
|
+
return {
|
|
141
|
+
"type": "deferred_tools",
|
|
142
|
+
"name": "available_deferred_tools",
|
|
143
|
+
"content_hash": _hash(text),
|
|
144
|
+
"char_count": len(text),
|
|
145
|
+
"estimated_tokens": _estimate_tokens(text),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def extract_skill_loads(messages_raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
150
|
+
"""Find Skill tool_use calls and their results in messages_raw.
|
|
151
|
+
|
|
152
|
+
Returns context items for each loaded skill, with the triggering user prompt.
|
|
153
|
+
"""
|
|
154
|
+
items: list[dict[str, Any]] = []
|
|
155
|
+
|
|
156
|
+
for i, msg in enumerate(messages_raw):
|
|
157
|
+
role = msg.get("role", "")
|
|
158
|
+
content = msg.get("content")
|
|
159
|
+
|
|
160
|
+
if role != "assistant" or not isinstance(content, list):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
for block in content:
|
|
164
|
+
if not isinstance(block, dict):
|
|
165
|
+
continue
|
|
166
|
+
if block.get("type") != "tool_use" or block.get("name") != "Skill":
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
tool_use_id = block.get("id", "")
|
|
170
|
+
skill_input = block.get("input", {})
|
|
171
|
+
skill_name = skill_input.get("skill", "unknown")
|
|
172
|
+
|
|
173
|
+
# Find the triggering user prompt: walk backwards from this message
|
|
174
|
+
triggered_by = ""
|
|
175
|
+
for j in range(i - 1, -1, -1):
|
|
176
|
+
prev = messages_raw[j]
|
|
177
|
+
if prev.get("role") != "user":
|
|
178
|
+
continue
|
|
179
|
+
prev_content = prev.get("content", "")
|
|
180
|
+
if isinstance(prev_content, str):
|
|
181
|
+
triggered_by = prev_content[:500]
|
|
182
|
+
break
|
|
183
|
+
if isinstance(prev_content, list):
|
|
184
|
+
# Skip tool_result blocks, find actual user text
|
|
185
|
+
has_tool_result = any(
|
|
186
|
+
isinstance(b, dict) and b.get("type") == "tool_result"
|
|
187
|
+
for b in prev_content
|
|
188
|
+
)
|
|
189
|
+
if has_tool_result:
|
|
190
|
+
continue
|
|
191
|
+
texts = [
|
|
192
|
+
b.get("text", "")
|
|
193
|
+
for b in prev_content
|
|
194
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
195
|
+
]
|
|
196
|
+
if texts:
|
|
197
|
+
triggered_by = "\n".join(texts)[:500]
|
|
198
|
+
break
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
# Find the tool_result and accompanying skill body text
|
|
202
|
+
skill_content = ""
|
|
203
|
+
for j in range(i + 1, len(messages_raw)):
|
|
204
|
+
next_msg = messages_raw[j]
|
|
205
|
+
if next_msg.get("role") != "user":
|
|
206
|
+
continue
|
|
207
|
+
next_content = next_msg.get("content")
|
|
208
|
+
if not isinstance(next_content, list):
|
|
209
|
+
continue
|
|
210
|
+
found_tool_result = False
|
|
211
|
+
skill_body_parts: list[str] = []
|
|
212
|
+
for nb in next_content:
|
|
213
|
+
if not isinstance(nb, dict):
|
|
214
|
+
continue
|
|
215
|
+
if nb.get("type") == "tool_result" and nb.get("tool_use_id") == tool_use_id:
|
|
216
|
+
found_tool_result = True
|
|
217
|
+
elif nb.get("type") == "text" and found_tool_result:
|
|
218
|
+
# Text blocks after the tool_result contain the loaded skill body
|
|
219
|
+
skill_body_parts.append(nb.get("text", ""))
|
|
220
|
+
if found_tool_result:
|
|
221
|
+
# Prefer skill body text over tool_result launch message
|
|
222
|
+
if skill_body_parts:
|
|
223
|
+
skill_content = "\n".join(skill_body_parts)
|
|
224
|
+
else:
|
|
225
|
+
# Fallback: extract from tool_result content itself
|
|
226
|
+
for nb in next_content:
|
|
227
|
+
if not isinstance(nb, dict):
|
|
228
|
+
continue
|
|
229
|
+
if nb.get("type") == "tool_result" and nb.get("tool_use_id") == tool_use_id:
|
|
230
|
+
tr_content = nb.get("content", "")
|
|
231
|
+
if isinstance(tr_content, str):
|
|
232
|
+
skill_content = tr_content
|
|
233
|
+
elif isinstance(tr_content, list):
|
|
234
|
+
parts = [
|
|
235
|
+
b.get("text", "")
|
|
236
|
+
for b in tr_content
|
|
237
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
238
|
+
]
|
|
239
|
+
skill_content = "\n".join(parts)
|
|
240
|
+
break
|
|
241
|
+
break
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
item: dict[str, Any] = {
|
|
245
|
+
"type": "skill_loaded",
|
|
246
|
+
"name": skill_name,
|
|
247
|
+
"content_hash": _hash(skill_content) if skill_content else "",
|
|
248
|
+
"char_count": len(skill_content),
|
|
249
|
+
"estimated_tokens": _estimate_tokens(skill_content) if skill_content else 0,
|
|
250
|
+
"message_index": i,
|
|
251
|
+
}
|
|
252
|
+
if triggered_by:
|
|
253
|
+
item["triggered_by"] = triggered_by
|
|
254
|
+
|
|
255
|
+
items.append(item)
|
|
256
|
+
|
|
257
|
+
return items
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def extract_user_prompt(messages_raw: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
261
|
+
"""Extract the last actual user prompt (not a tool_result) from messages_raw."""
|
|
262
|
+
for msg in reversed(messages_raw):
|
|
263
|
+
if msg.get("role") != "user":
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
content = msg.get("content", "")
|
|
267
|
+
|
|
268
|
+
# String content = direct user message
|
|
269
|
+
if isinstance(content, str):
|
|
270
|
+
return {
|
|
271
|
+
"type": "user_prompt",
|
|
272
|
+
"name": "user_input",
|
|
273
|
+
"content_hash": _hash(content),
|
|
274
|
+
"char_count": len(content),
|
|
275
|
+
"estimated_tokens": _estimate_tokens(content),
|
|
276
|
+
"preview": content[:200],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Array content — skip if it's only tool_results
|
|
280
|
+
if isinstance(content, list):
|
|
281
|
+
has_tool_result = any(
|
|
282
|
+
isinstance(b, dict) and b.get("type") == "tool_result"
|
|
283
|
+
for b in content
|
|
284
|
+
)
|
|
285
|
+
if has_tool_result:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
texts = [
|
|
289
|
+
b.get("text", "")
|
|
290
|
+
for b in content
|
|
291
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
292
|
+
]
|
|
293
|
+
if texts:
|
|
294
|
+
full = "\n".join(texts)
|
|
295
|
+
return {
|
|
296
|
+
"type": "user_prompt",
|
|
297
|
+
"name": "user_input",
|
|
298
|
+
"content_hash": _hash(full),
|
|
299
|
+
"char_count": len(full),
|
|
300
|
+
"estimated_tokens": _estimate_tokens(full),
|
|
301
|
+
"preview": full[:200],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def classify_context(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
|
308
|
+
"""Produce a full context inventory from a parsed snapshot.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
snapshot: A snapshot dict with system_prompt, messages, messages_raw,
|
|
312
|
+
tools, and injected_instructions fields.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
List of context item dicts, each with at minimum:
|
|
316
|
+
type, name, content_hash, char_count, estimated_tokens
|
|
317
|
+
"""
|
|
318
|
+
items: list[dict[str, Any]] = []
|
|
319
|
+
|
|
320
|
+
# 1. System prompt (core LLM instructions)
|
|
321
|
+
system_prompt = snapshot.get("system_prompt", "")
|
|
322
|
+
if system_prompt:
|
|
323
|
+
items.append({
|
|
324
|
+
"type": "system_prompt",
|
|
325
|
+
"name": "core_system_prompt",
|
|
326
|
+
"content_hash": _hash(system_prompt),
|
|
327
|
+
"char_count": len(system_prompt),
|
|
328
|
+
"estimated_tokens": _estimate_tokens(system_prompt),
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
# 2. Classify each system-reminder block
|
|
332
|
+
# Store instruction_index so the UI can look up the full content
|
|
333
|
+
# from injected_instructions without duplicating it here.
|
|
334
|
+
for idx, reminder_text in enumerate(snapshot.get("injected_instructions", [])):
|
|
335
|
+
item = classify_system_reminder(reminder_text)
|
|
336
|
+
item["instruction_index"] = idx
|
|
337
|
+
items.append(item)
|
|
338
|
+
|
|
339
|
+
# 3. Deferred tools blocks (in messages, outside system-reminders)
|
|
340
|
+
for msg in snapshot.get("messages", []):
|
|
341
|
+
content = msg.get("content", "")
|
|
342
|
+
if "<available-deferred-tools>" in content:
|
|
343
|
+
dt_item = classify_deferred_tools(content)
|
|
344
|
+
if dt_item:
|
|
345
|
+
items.append(dt_item)
|
|
346
|
+
|
|
347
|
+
# 4. Skill tool_use loads from messages_raw
|
|
348
|
+
messages_raw = snapshot.get("messages_raw", [])
|
|
349
|
+
skill_items = extract_skill_loads(messages_raw)
|
|
350
|
+
items.extend(skill_items)
|
|
351
|
+
|
|
352
|
+
# 5. Tool definitions summary
|
|
353
|
+
tools = snapshot.get("tools", [])
|
|
354
|
+
if tools:
|
|
355
|
+
tool_names = [t.get("name", "") for t in tools if isinstance(t, dict)]
|
|
356
|
+
tool_blob = ",".join(sorted(tool_names))
|
|
357
|
+
items.append({
|
|
358
|
+
"type": "tool_definitions",
|
|
359
|
+
"name": "available_tools",
|
|
360
|
+
"content_hash": _hash(tool_blob),
|
|
361
|
+
"char_count": sum(
|
|
362
|
+
len(str(t)) for t in tools
|
|
363
|
+
),
|
|
364
|
+
"estimated_tokens": sum(
|
|
365
|
+
_estimate_tokens(str(t)) for t in tools
|
|
366
|
+
),
|
|
367
|
+
"tool_names": tool_names,
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
# 6. Last user prompt
|
|
371
|
+
user_prompt = extract_user_prompt(messages_raw)
|
|
372
|
+
if user_prompt:
|
|
373
|
+
items.append(user_prompt)
|
|
374
|
+
|
|
375
|
+
return items
|