Skip to content

Commit 2e7bb82

Browse files
authored
Merge pull request #2176 from mito-ds/dev
Release Feb 5th, 2026
2 parents 59fd20a + 8db3e04 commit 2e7bb82

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1207
-175
lines changed

mito-ai/mito_ai/completions/prompt_builders/agent_system_message.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
get_database_rules
1111
)
1212
from mito_ai.completions.prompt_builders.prompt_section_registry.base import PromptSection
13+
from mito_ai.rules.utils import get_default_rules_content
1314

1415
def create_agent_system_message_prompt(isChromeBrowser: bool) -> str:
1516

@@ -474,7 +475,12 @@ def create_agent_system_message_prompt(isChromeBrowser: bool) -> str:
474475

475476
# Database rules
476477
sections.append(SG.Generic("Database Rules", get_database_rules()))
477-
478+
479+
# Default rules
480+
default_rules = get_default_rules_content()
481+
if default_rules:
482+
sections.append(SG.Generic("Default (User Defined) Rules", default_rules))
483+
478484
# RULES OF YOUR WORKING PROCESS
479485
sections.append(SG.Generic("Rules Of Working Process", f"""The user is going to ask you to guide them as through the process of completing a task. You will help them complete a task over the course of an entire conversation with them. The user will first share with you what they want to accomplish. You will then use a tool to execute the first step of the task, they will execute the tool and return to you the updated notebook state with you, and then you will give them the next step of the task. You will continue to give them the next step of the task until they have completed the task.
480486

mito-ai/mito_ai/completions/prompt_builders/chat_system_message.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
get_database_rules
1212
)
1313
from mito_ai.completions.prompt_builders.prompt_section_registry.base import PromptSection
14+
from mito_ai.rules.utils import get_default_rules_content
1415

1516
def create_chat_system_message_prompt() -> str:
1617
sections: List[PromptSection] = []
@@ -34,6 +35,9 @@ def create_chat_system_message_prompt() -> str:
3435

3536
sections.append(SG.Generic("Chart Config Rules", CHART_CONFIG_RULES))
3637
sections.append(SG.Generic("DatabaseRules", get_database_rules()))
38+
default_rules = get_default_rules_content()
39+
if default_rules:
40+
sections.append(SG.Generic("Default (User Defined) Rules", default_rules))
3741
sections.append(SG.Generic("Citation Rules", CITATION_RULES))
3842
sections.append(SG.Generic("Cell Reference Rules", CELL_REFERENCE_RULES))
3943

mito-ai/mito_ai/completions/prompt_builders/prompt_constants.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
- NEVER include comments on the same line as a variable assignment. Each variable assignment must be on its own line with no trailing comments.
2727
- For string values, use either single or double quotes (e.g., TITLE = "Sales by Product" or TITLE = 'Sales by Product'). Do not use nested quotes (e.g., do NOT use '"value"').
2828
29+
Fixed acceptable ranges (matplotlib constraints):
30+
- For numeric variables that have a fixed acceptable range, add a line immediately BEFORE the variable assignment: # RANGE VARIABLE_NAME MIN MAX
31+
- This allows the Chart Wizard to clamp inputs and prevent invalid values. Use the following ranges when you use these variables:
32+
- ALPHA (opacity): 0 1
33+
- FIGURE_SIZE (tuple width, height in inches): 1 24 (each element)
34+
- LINE_WIDTH, LINEWIDTH, LWD: 0 20
35+
- FONT_SIZE, FONTSIZE, FONT_SIZE_TITLE, FONT_SIZE_LABEL: 0.1 72
36+
- MARKER_SIZE, MARKERSIZE, S: 0 1000
37+
- DPI: 1 600
38+
- Any other numeric or tuple variable that you know has matplotlib constraints: add # RANGE VARIABLE_NAME MIN MAX with the appropriate min and max.
39+
2940
Common Mistakes to Avoid:
3041
- WRONG: COLOR = '"#1877F2" # Meta Blue' (nested quotes and inline comment)
3142
- WRONG: COLOR = "#1877F2" # Meta Blue (inline comment)
@@ -38,6 +49,10 @@
3849
X_LABEL = "Product"
3950
Y_LABEL = "Sales"
4051
BAR_COLOR = "#000000"
52+
# RANGE ALPHA 0 1
53+
ALPHA = 0.8
54+
# RANGE FIGURE_SIZE 1 24
55+
FIGURE_SIZE = (12, 6)
4156
# === END CONFIG ===
4257
"""
4358

mito-ai/mito_ai/rules/handlers.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@
77
import tornado
88
import os
99
from jupyter_server.base.handlers import APIHandler
10-
from mito_ai.rules.utils import RULES_DIR_PATH, get_all_rules, get_rule, set_rules_file
10+
from mito_ai.rules.utils import (
11+
RULES_DIR_PATH,
12+
cleanup_rules_metadata,
13+
delete_rule,
14+
get_all_rules,
15+
get_rule,
16+
get_rule_default,
17+
set_rule_default,
18+
set_rules_file,
19+
)
1120

1221

1322
class RulesHandler(APIHandler):
@@ -17,17 +26,26 @@ class RulesHandler(APIHandler):
1726
def get(self, key: Union[str, None] = None) -> None:
1827
"""Get a specific rule by key or all rules if no key provided"""
1928
if key is None or key == '':
20-
# No key provided, return all rules
21-
rules = get_all_rules()
29+
# No key provided, return all rules with is_default flag
30+
rule_files = get_all_rules()
31+
rules = [
32+
{"name": name, "is_default": get_rule_default(name)}
33+
for name in rule_files
34+
]
2235
self.finish(json.dumps(rules))
2336
else:
2437
# Key provided, return specific rule
25-
rule_content = get_rule(key)
26-
if rule_content is None:
27-
self.set_status(404)
28-
self.finish(json.dumps({"error": f"Rule with key '{key}' not found"}))
29-
else:
30-
self.finish(json.dumps({"key": key, "content": rule_content}))
38+
try:
39+
rule_content = get_rule(key)
40+
if rule_content is None:
41+
self.set_status(404)
42+
self.finish(json.dumps({"error": f"Rule with key '{key}' not found"}))
43+
else:
44+
is_default = get_rule_default(key)
45+
self.finish(json.dumps({"key": key, "content": rule_content, "is_default": is_default}))
46+
except ValueError as e:
47+
self.set_status(400)
48+
self.finish(json.dumps({"error": str(e)}))
3149

3250
@tornado.web.authenticated
3351
def put(self, key: str) -> None:
@@ -37,8 +55,24 @@ def put(self, key: str) -> None:
3755
self.set_status(400)
3856
self.finish(json.dumps({"error": "Content is required"}))
3957
return
40-
41-
set_rules_file(key, data['content'])
42-
self.finish(json.dumps({"status": "updated", "rules file ": key}))
4358

59+
try:
60+
set_rules_file(key, data['content'])
61+
if 'is_default' in data:
62+
set_rule_default(key, bool(data['is_default']))
63+
cleanup_rules_metadata()
64+
self.finish(json.dumps({"status": "updated", "rules_file": key}))
65+
except ValueError as e:
66+
self.set_status(400)
67+
self.finish(json.dumps({"error": str(e)}))
4468

69+
@tornado.web.authenticated
70+
def delete(self, key: str) -> None:
71+
"""Delete a rule by key (rule name)."""
72+
try:
73+
delete_rule(key)
74+
cleanup_rules_metadata()
75+
self.finish(json.dumps({"status": "deleted", "key": key}))
76+
except ValueError as e:
77+
self.set_status(400)
78+
self.finish(json.dumps({"error": str(e)}))

mito-ai/mito_ai/rules/utils.py

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,166 @@
11
# Copyright (c) Saga Inc.
22
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
33

4-
from typing import Any, Final, List, Optional
4+
from typing import Any, Final, List, Optional, cast
55
import os
6+
import json
67
from mito_ai.utils.schema import MITO_FOLDER
78

89
RULES_DIR_PATH: Final[str] = os.path.join(MITO_FOLDER, 'rules')
10+
RULES_METADATA_FILENAME: Final[str] = '_metadata.json'
11+
12+
13+
def _sanitize_rule_name(rule_name: str) -> str:
14+
"""
15+
Sanitizes a rule name to prevent path traversal attacks.
16+
Raises ValueError if the rule name contains unsafe characters.
17+
18+
Args:
19+
rule_name: The rule name to sanitize
20+
21+
Returns:
22+
The sanitized rule name (with .md extension stripped if present)
23+
24+
Raises:
25+
ValueError: If the rule name contains path traversal sequences or other unsafe characters
26+
"""
27+
if not rule_name:
28+
raise ValueError("Rule name cannot be empty")
29+
30+
# Strip .md extension if present
31+
if rule_name.endswith('.md'):
32+
rule_name = rule_name[:-3]
33+
34+
# Check for path traversal sequences
35+
if '..' in rule_name or '/' in rule_name or '\\' in rule_name:
36+
raise ValueError(f"Rule name contains invalid characters: {rule_name}")
37+
38+
# Check for absolute paths
39+
if os.path.isabs(rule_name):
40+
raise ValueError(f"Rule name cannot be an absolute path: {rule_name}")
41+
42+
# Check for null bytes or other control characters
43+
if '\x00' in rule_name:
44+
raise ValueError("Rule name cannot contain null bytes")
45+
46+
# Ensure it's a valid filename (no reserved characters on Windows)
47+
# Windows reserved: < > : " | ? *
48+
invalid_chars = set('<>:|?*"')
49+
if any(c in rule_name for c in invalid_chars):
50+
raise ValueError(f"Rule name contains invalid filename characters: {rule_name}")
51+
52+
return rule_name
53+
54+
55+
def _validate_rule_path(file_path: str, rule_name: str) -> None:
56+
"""
57+
Validates that a rule file path is within the rules directory.
58+
This provides defense-in-depth protection against path traversal attacks.
59+
60+
Args:
61+
file_path: The file path to validate
62+
rule_name: The rule name (for error messages)
63+
64+
Raises:
65+
ValueError: If the resolved path is outside RULES_DIR_PATH
66+
"""
67+
resolved_path = os.path.abspath(file_path)
68+
rules_dir_abs = os.path.abspath(RULES_DIR_PATH)
69+
if not resolved_path.startswith(rules_dir_abs):
70+
raise ValueError(f"Invalid rule name: {rule_name}")
71+
72+
73+
def _get_metadata_path() -> str:
74+
return os.path.join(RULES_DIR_PATH, RULES_METADATA_FILENAME)
75+
76+
77+
def _load_metadata() -> dict[Any, Any]:
78+
path = _get_metadata_path()
79+
if not os.path.exists(path):
80+
return {}
81+
try:
82+
with open(path, 'r') as f:
83+
return cast(dict[Any, Any], json.load(f))
84+
except (json.JSONDecodeError, OSError):
85+
return {}
86+
87+
88+
def _save_metadata(metadata: dict) -> None:
89+
if not os.path.exists(RULES_DIR_PATH):
90+
os.makedirs(RULES_DIR_PATH)
91+
path = _get_metadata_path()
92+
with open(path, 'w') as f:
93+
json.dump(metadata, f, indent=2)
94+
95+
96+
def get_rule_default(rule_name: str) -> bool:
97+
"""Returns whether the rule is marked as a default (auto-applied) rule."""
98+
if rule_name.endswith('.md'):
99+
rule_name = rule_name[:-3]
100+
metadata = _load_metadata()
101+
entry = metadata.get(rule_name, {})
102+
return bool(entry.get('is_default', False))
103+
104+
105+
def set_rule_default(rule_name: str, is_default: bool) -> None:
106+
"""Sets whether the rule is a default (auto-applied) rule."""
107+
if rule_name.endswith('.md'):
108+
rule_name = rule_name[:-3]
109+
metadata = _load_metadata()
110+
metadata[rule_name] = {**metadata.get(rule_name, {}), 'is_default': is_default}
111+
_save_metadata(metadata)
112+
9113

10114
def set_rules_file(rule_name: str, value: Any) -> None:
11115
"""
12116
Updates the value of a specific rule file in the rules directory
13117
"""
118+
# Sanitize rule name to prevent path traversal
119+
rule_name = _sanitize_rule_name(rule_name)
120+
14121
# Ensure the directory exists
15122
if not os.path.exists(RULES_DIR_PATH):
16123
os.makedirs(RULES_DIR_PATH)
17-
124+
18125
# Create the file path to the rule name as a .md file
19126
file_path = os.path.join(RULES_DIR_PATH, f"{rule_name}.md")
20127

128+
# Additional safety check: ensure the resolved path is still within RULES_DIR_PATH
129+
_validate_rule_path(file_path, rule_name)
130+
21131
with open(file_path, 'w+') as f:
22132
f.write(value)
133+
134+
135+
def delete_rule(rule_name: str) -> None:
136+
"""
137+
Deletes a rule file from the rules directory. Normalizes rule_name (strips .md).
138+
Metadata for this rule is removed by cleanup_rules_metadata().
139+
"""
140+
# Sanitize rule name to prevent path traversal
141+
rule_name = _sanitize_rule_name(rule_name)
142+
143+
file_path = os.path.join(RULES_DIR_PATH, f"{rule_name}.md")
144+
145+
# Additional safety check: ensure the resolved path is still within RULES_DIR_PATH
146+
_validate_rule_path(file_path, rule_name)
23147

148+
if os.path.exists(file_path):
149+
os.remove(file_path)
150+
24151

25152
def get_rule(rule_name: str) -> Optional[str]:
26153
"""
27154
Retrieves the value of a specific rule file from the rules directory
28155
"""
29-
30-
if rule_name.endswith('.md'):
31-
rule_name = rule_name[:-3]
156+
# Sanitize rule name to prevent path traversal
157+
rule_name = _sanitize_rule_name(rule_name)
32158

33159
file_path = os.path.join(RULES_DIR_PATH, f"{rule_name}.md")
34160

161+
# Additional safety check: ensure the resolved path is still within RULES_DIR_PATH
162+
_validate_rule_path(file_path, rule_name)
163+
35164
if not os.path.exists(file_path):
36165
return None
37166

@@ -47,10 +176,45 @@ def get_all_rules() -> List[str]:
47176
if not os.path.exists(RULES_DIR_PATH):
48177
os.makedirs(RULES_DIR_PATH)
49178
return [] # Return empty list if directory didn't exist
50-
179+
51180
try:
52181
return [f for f in os.listdir(RULES_DIR_PATH) if f.endswith('.md')]
53182
except OSError as e:
54183
# Log the error if needed and return empty list
55184
print(f"Error reading rules directory: {e}")
56185
return []
186+
187+
188+
def cleanup_rules_metadata() -> None:
189+
"""
190+
Removes metadata entries for rules that no longer exist on disk (deleted or renamed).
191+
Call after rule create/update so metadata stays in sync with actual rule files.
192+
"""
193+
current_files = get_all_rules()
194+
current_rule_names = {f[:-3] if f.endswith('.md') else f for f in current_files}
195+
metadata = _load_metadata()
196+
if not metadata:
197+
return
198+
keys_to_remove = [k for k in metadata if k not in current_rule_names]
199+
if not keys_to_remove:
200+
return
201+
for k in keys_to_remove:
202+
del metadata[k]
203+
_save_metadata(metadata)
204+
205+
206+
def get_default_rules_content() -> str:
207+
"""
208+
Returns the concatenated content of all rules marked as default (auto-applied).
209+
Each rule is included as "Rule name:\n\n{content}". Returns empty string if no default rules.
210+
"""
211+
rule_files = get_all_rules()
212+
parts: List[str] = []
213+
for f in rule_files:
214+
rule_name = f[:-3] if f.endswith('.md') else f
215+
if not get_rule_default(rule_name):
216+
continue
217+
content = get_rule(rule_name)
218+
if content and content.strip():
219+
parts.append(f"{rule_name}:\n\n{content}")
220+
return '\n\n'.join(parts) if parts else ""

0 commit comments

Comments
 (0)