@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.
Files changed (47) hide show
  1. package/Dockerfile +28 -0
  2. package/addon/context_classifier.py +375 -0
  3. package/addon/providers.py +483 -0
  4. package/addon/reporter.py +778 -0
  5. package/addon/requirements.txt +1 -0
  6. package/addon/rulemetric_addon.py +1288 -0
  7. package/addon/secret_redactor.py +130 -0
  8. package/addon/security_scanner.py +828 -0
  9. package/addon/session_linker.py +206 -0
  10. package/addon/sse_parser.py +364 -0
  11. package/dist/index.d.ts +10 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +9 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts +8 -0
  16. package/dist/providers/anthropic.d.ts.map +1 -0
  17. package/dist/providers/anthropic.js +135 -0
  18. package/dist/providers/anthropic.js.map +1 -0
  19. package/dist/providers/base.d.ts +11 -0
  20. package/dist/providers/base.d.ts.map +1 -0
  21. package/dist/providers/base.js +43 -0
  22. package/dist/providers/base.js.map +1 -0
  23. package/dist/providers/bedrock.d.ts +8 -0
  24. package/dist/providers/bedrock.d.ts.map +1 -0
  25. package/dist/providers/bedrock.js +125 -0
  26. package/dist/providers/bedrock.js.map +1 -0
  27. package/dist/providers/gemini.d.ts +9 -0
  28. package/dist/providers/gemini.d.ts.map +1 -0
  29. package/dist/providers/gemini.js +140 -0
  30. package/dist/providers/gemini.js.map +1 -0
  31. package/dist/providers/index.d.ts +8 -0
  32. package/dist/providers/index.d.ts.map +1 -0
  33. package/dist/providers/index.js +116 -0
  34. package/dist/providers/index.js.map +1 -0
  35. package/dist/providers/openai.d.ts +9 -0
  36. package/dist/providers/openai.d.ts.map +1 -0
  37. package/dist/providers/openai.js +129 -0
  38. package/dist/providers/openai.js.map +1 -0
  39. package/dist/session-linker.d.ts +6 -0
  40. package/dist/session-linker.d.ts.map +1 -0
  41. package/dist/session-linker.js +68 -0
  42. package/dist/session-linker.js.map +1 -0
  43. package/dist/types.d.ts +41 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +2 -0
  46. package/dist/types.js.map +1 -0
  47. 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