@pheem49/mint 1.5.5 → 1.6.1

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 (222) hide show
  1. package/.codex +0 -0
  2. package/.github/FUNDING.yml +2 -0
  3. package/.github/workflows/ci.yml +45 -0
  4. package/.github/workflows/release.yml +79 -0
  5. package/Cargo.lock +5792 -0
  6. package/Cargo.toml +32 -0
  7. package/README.md +387 -353
  8. package/assets/icon.png +0 -0
  9. package/bin/mint +0 -0
  10. package/crates/mint-cli/Cargo.toml +23 -0
  11. package/crates/mint-cli/src/agent.rs +851 -0
  12. package/crates/mint-cli/src/gmail.rs +216 -0
  13. package/crates/mint-cli/src/image.rs +142 -0
  14. package/crates/mint-cli/src/main.rs +2837 -0
  15. package/crates/mint-cli/src/mcp.rs +63 -0
  16. package/crates/mint-cli/src/onboard.rs +1149 -0
  17. package/crates/mint-cli/src/setup.rs +390 -0
  18. package/crates/mint-cli/src/skills.rs +8 -0
  19. package/crates/mint-cli/src/updater.rs +279 -0
  20. package/crates/mint-core/Cargo.toml +22 -0
  21. package/crates/mint-core/src/agent_loop.rs +94 -0
  22. package/crates/mint-core/src/api_server.rs +991 -0
  23. package/crates/mint-core/src/channels.rs +248 -0
  24. package/crates/mint-core/src/chat.rs +895 -0
  25. package/crates/mint-core/src/code_tools.rs +729 -0
  26. package/crates/mint-core/src/config.rs +368 -0
  27. package/crates/mint-core/src/files.rs +159 -0
  28. package/crates/mint-core/src/knowledge.rs +541 -0
  29. package/crates/mint-core/src/lib.rs +84 -0
  30. package/crates/mint-core/src/mcp.rs +273 -0
  31. package/crates/mint-core/src/memory.rs +673 -0
  32. package/crates/mint-core/src/orchestration.rs +2157 -0
  33. package/crates/mint-core/src/pictures.rs +314 -0
  34. package/crates/mint-core/src/plugins.rs +727 -0
  35. package/crates/mint-core/src/safety.rs +416 -0
  36. package/crates/mint-core/src/semantic.rs +254 -0
  37. package/crates/mint-core/src/shell.rs +317 -0
  38. package/crates/mint-core/src/skills.rs +71 -0
  39. package/crates/mint-core/src/symbols.rs +157 -0
  40. package/crates/mint-core/src/tasks.rs +308 -0
  41. package/crates/mint-core/src/tts.rs +92 -0
  42. package/crates/mint-core/src/weather.rs +93 -0
  43. package/crates/mint-core/src/web_search.rs +200 -0
  44. package/crates/mint-core/src/workflows.rs +81 -0
  45. package/crates/mint-core/tests/mcp_stdio.rs +45 -0
  46. package/crates/mint-core/tests/memory_persistence.rs +172 -0
  47. package/crates/mint-core/tests/pictures_storage.rs +14 -0
  48. package/crates/mint-core/tests/task_lifecycle.rs +87 -0
  49. package/package.json +35 -99
  50. package/src/bin/index.js +16 -0
  51. package/src/renderer/index-web.html +17 -0
  52. package/src/renderer/index.html +17 -0
  53. package/src/renderer/public/Live2DCubismCore.js +9 -0
  54. package/src/renderer/public/assets/icon.png +0 -0
  55. package/src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.model3.json +36 -0
  56. package/src/renderer/src/App.tsx +33 -0
  57. package/src/renderer/src/calculator.ts +47 -0
  58. package/src/renderer/src/components/ChatPanel.tsx +1598 -0
  59. package/src/renderer/src/components/DashboardSidebar.tsx +358 -0
  60. package/src/renderer/src/components/Live2DStage.tsx +374 -0
  61. package/src/renderer/src/components/MintDashboard.tsx +950 -0
  62. package/src/renderer/src/components/ModelPanel.tsx +154 -0
  63. package/src/renderer/src/components/PicturesLibrary.tsx +46 -0
  64. package/src/renderer/src/components/ProactiveGlow.tsx +19 -0
  65. package/src/renderer/src/components/ScreenPicker.tsx +579 -0
  66. package/src/renderer/src/components/SettingsWindow.tsx +1467 -0
  67. package/src/renderer/src/components/SpotlightWindow.tsx +280 -0
  68. package/src/renderer/src/components/WidgetWindow.tsx +36 -0
  69. package/src/renderer/src/components/WorkspacePanel.tsx +268 -0
  70. package/src/{UI → renderer/src/css}/settings.css +69 -16
  71. package/src/renderer/src/css/spotlight.css +113 -0
  72. package/src/renderer/src/css/styles.css +3722 -0
  73. package/src/renderer/src/css/widget.css +185 -0
  74. package/src/renderer/src/env.d.ts +116 -0
  75. package/src/renderer/src/index.css +379 -0
  76. package/src/renderer/src/main.tsx +13 -0
  77. package/src/renderer/src/tauri.ts +996 -0
  78. package/src/renderer/src-web/App.tsx +25 -0
  79. package/src/renderer/src-web/calculator.ts +47 -0
  80. package/src/renderer/src-web/components/ChatPanel.tsx +1662 -0
  81. package/src/renderer/src-web/components/DashboardSidebar.tsx +242 -0
  82. package/src/renderer/src-web/components/MintDashboard.tsx +763 -0
  83. package/src/renderer/src-web/components/PicturesLibrary.tsx +73 -0
  84. package/src/renderer/src-web/components/SettingsWindow.tsx +1500 -0
  85. package/src/renderer/src-web/css/settings.css +1100 -0
  86. package/src/{UI → renderer/src-web/css}/spotlight.css +4 -4
  87. package/src/{UI → renderer/src-web/css}/styles.css +1055 -159
  88. package/src/{UI → renderer/src-web/css}/widget.css +2 -2
  89. package/src/renderer/src-web/env.d.ts +107 -0
  90. package/src/renderer/src-web/index.css +379 -0
  91. package/src/renderer/src-web/main.tsx +13 -0
  92. package/src/renderer/src-web/tauri.ts +983 -0
  93. package/tsconfig.json +30 -0
  94. package/vite.config.ts +33 -0
  95. package/vite.config.web.ts +51 -0
  96. package/GUIDE_TH.md +0 -125
  97. package/assets/Agent_Mint.png +0 -0
  98. package/assets/CLI_Screen.png +0 -0
  99. package/assets/Settings.png +0 -0
  100. package/benchmark_ai.js +0 -71
  101. package/install.ps1 +0 -64
  102. package/install.sh +0 -54
  103. package/main.js +0 -139
  104. package/mint-cli-logic.js +0 -3
  105. package/mint-cli.js +0 -410
  106. package/models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.model3.json +0 -47
  107. package/models/Shiroko_Model/Shiroko//342/232/241/351/253/230/344/272/256/342/232/241/344/275/277/347/224/250/346/225/231/347/250/213/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.txt +0 -23
  108. package/preload-picker.js +0 -11
  109. package/preload-settings.js +0 -11
  110. package/preload.js +0 -41
  111. package/scripts/install_linux_desktop_entry.js +0 -48
  112. package/src/AI_Brain/Gemini_API.js +0 -813
  113. package/src/AI_Brain/agent_orchestrator.js +0 -73
  114. package/src/AI_Brain/autonomous_brain.js +0 -179
  115. package/src/AI_Brain/behavior_memory.js +0 -135
  116. package/src/AI_Brain/headless_agent.js +0 -143
  117. package/src/AI_Brain/knowledge_base.js +0 -349
  118. package/src/AI_Brain/memory_store.js +0 -662
  119. package/src/AI_Brain/proactive_engine.js +0 -172
  120. package/src/AI_Brain/provider_adapter.js +0 -365
  121. package/src/Automation_Layer/browser_automation.js +0 -149
  122. package/src/Automation_Layer/file_operations.js +0 -286
  123. package/src/Automation_Layer/open_app.js +0 -85
  124. package/src/Automation_Layer/open_website.js +0 -38
  125. package/src/CLI/approval_handler.js +0 -47
  126. package/src/CLI/chat_router.js +0 -247
  127. package/src/CLI/chat_ui.js +0 -1159
  128. package/src/CLI/cli_colors.js +0 -115
  129. package/src/CLI/cli_formatters.js +0 -94
  130. package/src/CLI/code_agent.js +0 -1667
  131. package/src/CLI/code_session_memory.js +0 -62
  132. package/src/CLI/gmail_auth.js +0 -210
  133. package/src/CLI/image_input.js +0 -90
  134. package/src/CLI/intent_detectors.js +0 -181
  135. package/src/CLI/interactive_chat.js +0 -658
  136. package/src/CLI/list_features.js +0 -64
  137. package/src/CLI/onboarding.js +0 -416
  138. package/src/CLI/repo_summarizer.js +0 -282
  139. package/src/CLI/semantic_code_search.js +0 -312
  140. package/src/CLI/skill_manager.js +0 -41
  141. package/src/CLI/slash_command_handler.js +0 -418
  142. package/src/CLI/symbol_indexer.js +0 -231
  143. package/src/CLI/updater.js +0 -230
  144. package/src/CLI/workspace_manager.js +0 -90
  145. package/src/Channels/brave_search_bridge.js +0 -35
  146. package/src/Channels/discord_bridge.js +0 -66
  147. package/src/Channels/google_search_bridge.js +0 -38
  148. package/src/Channels/line_bridge.js +0 -60
  149. package/src/Channels/slack_bridge.js +0 -48
  150. package/src/Channels/telegram_bridge.js +0 -41
  151. package/src/Channels/whatsapp_bridge.js +0 -57
  152. package/src/Command_Parser/parser.js +0 -45
  153. package/src/Plugins/dev_tools.js +0 -41
  154. package/src/Plugins/discord.js +0 -20
  155. package/src/Plugins/docker.js +0 -47
  156. package/src/Plugins/gmail.js +0 -251
  157. package/src/Plugins/google_calendar.js +0 -252
  158. package/src/Plugins/mcp_manager.js +0 -95
  159. package/src/Plugins/notion.js +0 -256
  160. package/src/Plugins/obsidian.js +0 -54
  161. package/src/Plugins/plugin_manager.js +0 -81
  162. package/src/Plugins/spotify.js +0 -173
  163. package/src/Plugins/system_metrics.js +0 -31
  164. package/src/Plugins/system_monitor.js +0 -72
  165. package/src/System/action_executor.js +0 -178
  166. package/src/System/bridge_manager.js +0 -76
  167. package/src/System/chat_history_manager.js +0 -83
  168. package/src/System/config_manager.js +0 -194
  169. package/src/System/custom_workflows.js +0 -163
  170. package/src/System/daemon_manager.js +0 -67
  171. package/src/System/google_tts_urls.js +0 -51
  172. package/src/System/granular_automation.js +0 -157
  173. package/src/System/ipc_handlers.js +0 -332
  174. package/src/System/notifications.js +0 -23
  175. package/src/System/optional_require.js +0 -23
  176. package/src/System/picture_store.js +0 -109
  177. package/src/System/proactive_loop.js +0 -153
  178. package/src/System/safety_manager.js +0 -273
  179. package/src/System/sandbox_runner.js +0 -182
  180. package/src/System/screen_capture.js +0 -175
  181. package/src/System/smart_context.js +0 -227
  182. package/src/System/system_automation.js +0 -162
  183. package/src/System/system_events.js +0 -79
  184. package/src/System/system_info.js +0 -125
  185. package/src/System/task_manager.js +0 -222
  186. package/src/System/tool_registry.js +0 -293
  187. package/src/System/window_manager.js +0 -220
  188. package/src/UI/floating.css +0 -80
  189. package/src/UI/floating.html +0 -17
  190. package/src/UI/floating.js +0 -67
  191. package/src/UI/live2d_manager.js +0 -600
  192. package/src/UI/preload-floating.js +0 -7
  193. package/src/UI/preload-spotlight.js +0 -11
  194. package/src/UI/preload-widget.js +0 -5
  195. package/src/UI/proactive-glow.html +0 -42
  196. package/src/UI/renderer.js +0 -2127
  197. package/src/UI/screenPicker.html +0 -214
  198. package/src/UI/screenPicker.js +0 -262
  199. package/src/UI/settings.html +0 -577
  200. package/src/UI/settings.js +0 -770
  201. package/src/UI/spotlight.html +0 -23
  202. package/src/UI/spotlight.js +0 -185
  203. package/src/UI/widget.html +0 -29
  204. package/src/UI/widget.js +0 -10
  205. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/72d86db84cfa9730b894c241fd24c0db.png +0 -0
  206. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/233/264/350/243/231.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/apron.exp3.json} +0 -0
  207. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/214/253/345/222/252/346/273/244/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/catfilter.exp3.json} +0 -0
  208. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/click.exp3.json} +0 -0
  209. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazed.exp3.json} +0 -0
  210. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253/347/234/274/347/217/240/346/221/207/346/231/203.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazedeyes.exp3.json} +0 -0
  211. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/234/274/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/glasses.exp3.json} +0 -0
  212. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/items_pinned_to_model.json +0 -0
  213. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/277/347/254/224.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/pen.exp3.json} +0 -0
  214. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/215/347/205/247.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/photo.exp3.json} +0 -0
  215. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_00.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_00.png} +0 -0
  216. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_01.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_01.png} +0 -0
  217. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_02.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_02.png} +0 -0
  218. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_03.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_03.png} +0 -0
  219. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.cdi3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.cdi3.json} +0 -0
  220. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.moc3" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.moc3} +0 -0
  221. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.physics3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.physics3.json} +0 -0
  222. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.vtube.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.vtube.json} +0 -0
@@ -0,0 +1,416 @@
1
+ use std::{
2
+ path::{Component, Path, PathBuf},
3
+ sync::LazyLock,
4
+ };
5
+
6
+ use regex::Regex;
7
+ use serde::Serialize;
8
+ use thiserror::Error;
9
+
10
+ use crate::MintConfig;
11
+
12
+ static BLOCKED_COMMAND_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
13
+ [
14
+ (
15
+ r"\brm\s+(-[^\s]*r[^\s]*f|-rf|-fr)\b",
16
+ "recursive force delete",
17
+ ),
18
+ (r"\bgit\s+reset\s+--hard\b", "destructive git reset"),
19
+ (
20
+ r"\bgit\s+checkout\s+--\b",
21
+ "destructive git checkout path restore",
22
+ ),
23
+ (r"\bgit\s+clean\b.*\s-[^\s]*f", "destructive git clean"),
24
+ (r"\bmkfs(?:\.\w+)?\b", "filesystem formatting"),
25
+ (r"\bdd\s+.*\bof=/dev/", "raw disk write"),
26
+ (
27
+ r">\s*/dev/(?:sd|nvme|hd|mapper)",
28
+ "write redirection to block device",
29
+ ),
30
+ (
31
+ r"\b(shutdown|reboot|poweroff|halt)\b",
32
+ "system power command",
33
+ ),
34
+ (r"\bsudo\b", "privilege escalation"),
35
+ (r"\bchmod\s+-R\s+777\b", "unsafe recursive permissions"),
36
+ (r"\bchown\s+-R\b", "unsafe recursive ownership change"),
37
+ (
38
+ r"\b(curl|wget)\b.*\|\s*(sh|bash|zsh)\b",
39
+ "remote script piping",
40
+ ),
41
+ ]
42
+ .into_iter()
43
+ .map(|(pattern, reason)| (Regex::new(pattern).unwrap(), reason))
44
+ .collect()
45
+ });
46
+
47
+ #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
48
+ #[serde(rename_all = "lowercase")]
49
+ pub enum SafetyTier {
50
+ Approval,
51
+ Blocked,
52
+ }
53
+
54
+ #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
55
+ #[serde(rename_all = "camelCase")]
56
+ pub enum ShellCommandMode {
57
+ ReadOnly,
58
+ Test,
59
+ Network,
60
+ Mutating,
61
+ }
62
+
63
+ impl ShellCommandMode {
64
+ pub fn as_str(self) -> &'static str {
65
+ match self {
66
+ Self::ReadOnly => "readOnly",
67
+ Self::Test => "test",
68
+ Self::Network => "network",
69
+ Self::Mutating => "mutating",
70
+ }
71
+ }
72
+ }
73
+
74
+ #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
75
+ pub struct ShellClassification {
76
+ pub tier: SafetyTier,
77
+ pub mode: ShellCommandMode,
78
+ pub reason: String,
79
+ }
80
+
81
+ #[derive(Debug, Clone, Copy)]
82
+ pub enum Capability {
83
+ Read,
84
+ Write,
85
+ }
86
+
87
+ impl Capability {
88
+ fn as_str(self) -> &'static str {
89
+ match self {
90
+ Self::Read => "read",
91
+ Self::Write => "write",
92
+ }
93
+ }
94
+ }
95
+
96
+ #[derive(Debug, Error, PartialEq, Eq)]
97
+ pub enum SafetyError {
98
+ #[error("target path is required")]
99
+ TargetPathRequired,
100
+ #[error("blocked {capability} access to sensitive file name: {file_name}")]
101
+ BlockedFileName {
102
+ capability: &'static str,
103
+ file_name: String,
104
+ },
105
+ #[error("blocked {capability} access to protected path: {path}")]
106
+ BlockedPath {
107
+ capability: &'static str,
108
+ path: PathBuf,
109
+ },
110
+ #[error("path {capability} denied by capability policy: {path}")]
111
+ PathDenied {
112
+ capability: &'static str,
113
+ path: PathBuf,
114
+ },
115
+ }
116
+
117
+ pub fn classify_shell_command(command: &str) -> ShellClassification {
118
+ let normalized = command.split_whitespace().collect::<Vec<_>>().join(" ");
119
+ if normalized.is_empty() {
120
+ return ShellClassification {
121
+ tier: SafetyTier::Blocked,
122
+ mode: ShellCommandMode::Mutating,
123
+ reason: "empty shell command".into(),
124
+ };
125
+ }
126
+ for (pattern, reason) in BLOCKED_COMMAND_PATTERNS.iter() {
127
+ if pattern.is_match(&normalized) {
128
+ return ShellClassification {
129
+ tier: SafetyTier::Blocked,
130
+ mode: ShellCommandMode::Mutating,
131
+ reason: (*reason).into(),
132
+ };
133
+ }
134
+ }
135
+ let mode = classify_shell_mode(&normalized);
136
+ ShellClassification {
137
+ tier: SafetyTier::Approval,
138
+ mode,
139
+ reason: format!("{} shell command requires approval", mode.as_str()),
140
+ }
141
+ }
142
+
143
+ pub fn shell_mode_allowed(config: &MintConfig, mode: ShellCommandMode) -> bool {
144
+ config
145
+ .extra
146
+ .get("allowedShellModes")
147
+ .and_then(|value| value.as_array())
148
+ .map(|values| {
149
+ values
150
+ .iter()
151
+ .filter_map(|value| value.as_str())
152
+ .any(|value| value == "*" || value == mode.as_str())
153
+ })
154
+ .unwrap_or(false)
155
+ }
156
+
157
+ fn classify_shell_mode(command: &str) -> ShellCommandMode {
158
+ let lower = command.to_ascii_lowercase();
159
+ if contains_network_command(&lower) {
160
+ return ShellCommandMode::Network;
161
+ }
162
+ if is_test_command(&lower) {
163
+ return ShellCommandMode::Test;
164
+ }
165
+ if is_read_only_command(&lower) {
166
+ return ShellCommandMode::ReadOnly;
167
+ }
168
+ ShellCommandMode::Mutating
169
+ }
170
+
171
+ fn contains_network_command(command: &str) -> bool {
172
+ [
173
+ "curl ",
174
+ "wget ",
175
+ "git clone",
176
+ "npm install",
177
+ "npm ci",
178
+ "pnpm install",
179
+ "pnpm add",
180
+ "yarn add",
181
+ "pip install",
182
+ "cargo install",
183
+ "go install",
184
+ "docker pull",
185
+ ]
186
+ .iter()
187
+ .any(|needle| command.contains(needle))
188
+ }
189
+
190
+ fn is_test_command(command: &str) -> bool {
191
+ [
192
+ "cargo test",
193
+ "cargo check",
194
+ "cargo clippy",
195
+ "cargo fmt",
196
+ "npm test",
197
+ "npm run test",
198
+ "npm run -s test",
199
+ "npm run build",
200
+ "npm run -s build",
201
+ "npm run lint",
202
+ "npm run -s lint",
203
+ "npm run check",
204
+ "npm run -s check",
205
+ "npm run typecheck",
206
+ "npm run -s typecheck",
207
+ "pnpm test",
208
+ "pnpm run test",
209
+ "pnpm run build",
210
+ "yarn test",
211
+ "yarn build",
212
+ "pytest",
213
+ "go test",
214
+ ]
215
+ .iter()
216
+ .any(|prefix| command == *prefix || command.starts_with(&format!("{prefix} ")))
217
+ }
218
+
219
+ fn is_read_only_command(command: &str) -> bool {
220
+ if command.contains('>') || command.contains(" tee ") {
221
+ return false;
222
+ }
223
+ command
224
+ .split([';', '&', '|'])
225
+ .map(str::trim)
226
+ .filter(|segment| !segment.is_empty())
227
+ .all(|segment| {
228
+ let mut words = segment.split_whitespace();
229
+ matches!(
230
+ words.next(),
231
+ Some(
232
+ "cat"
233
+ | "cd"
234
+ | "du"
235
+ | "find"
236
+ | "grep"
237
+ | "git"
238
+ | "head"
239
+ | "ls"
240
+ | "nl"
241
+ | "pwd"
242
+ | "rg"
243
+ | "sed"
244
+ | "tail"
245
+ | "tree"
246
+ | "wc"
247
+ | "which"
248
+ )
249
+ ) && !segment.starts_with("git ")
250
+ || is_read_only_git(segment)
251
+ })
252
+ }
253
+
254
+ fn is_read_only_git(segment: &str) -> bool {
255
+ let mut words = segment.split_whitespace();
256
+ if words.next() != Some("git") {
257
+ return false;
258
+ }
259
+ matches!(
260
+ words.next(),
261
+ Some("branch" | "diff" | "log" | "show" | "status")
262
+ )
263
+ }
264
+
265
+ pub fn assert_path_capability(
266
+ target_path: &Path,
267
+ capability: Capability,
268
+ config: &MintConfig,
269
+ ) -> Result<PathBuf, SafetyError> {
270
+ if target_path.as_os_str().is_empty() {
271
+ return Err(SafetyError::TargetPathRequired);
272
+ }
273
+ let resolved = resolve_path(target_path);
274
+ if !config.safety_enabled {
275
+ return Ok(resolved);
276
+ }
277
+ if let Some(file_name) = resolved.file_name().and_then(|value| value.to_str())
278
+ && config
279
+ .blocked_file_names
280
+ .iter()
281
+ .any(|item| item == file_name)
282
+ {
283
+ return Err(SafetyError::BlockedFileName {
284
+ capability: capability.as_str(),
285
+ file_name: file_name.into(),
286
+ });
287
+ }
288
+ if config
289
+ .blocked_paths
290
+ .iter()
291
+ .map(|path| resolve_path(path))
292
+ .any(|path| resolved.starts_with(path))
293
+ {
294
+ return Err(SafetyError::BlockedPath {
295
+ capability: capability.as_str(),
296
+ path: resolved,
297
+ });
298
+ }
299
+ let allowed_paths = match capability {
300
+ Capability::Read => &config.allowed_read_paths,
301
+ Capability::Write => &config.allowed_write_paths,
302
+ };
303
+ if !allowed_paths
304
+ .iter()
305
+ .map(|path| resolve_path(path))
306
+ .any(|path| resolved.starts_with(path))
307
+ {
308
+ return Err(SafetyError::PathDenied {
309
+ capability: capability.as_str(),
310
+ path: resolved,
311
+ });
312
+ }
313
+ Ok(resolved)
314
+ }
315
+
316
+ fn resolve_path(path: &Path) -> PathBuf {
317
+ let expanded = expand_home(path);
318
+ let absolute = if expanded.is_absolute() {
319
+ expanded
320
+ } else {
321
+ std::env::current_dir()
322
+ .unwrap_or_else(|_| PathBuf::from("."))
323
+ .join(expanded)
324
+ };
325
+ normalize_path(&absolute)
326
+ }
327
+
328
+ fn expand_home(path: &Path) -> PathBuf {
329
+ let value = path.to_string_lossy();
330
+ if value == "~" {
331
+ return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
332
+ }
333
+ if let Some(remainder) = value.strip_prefix("~/")
334
+ && let Some(home) = dirs::home_dir()
335
+ {
336
+ return home.join(remainder);
337
+ }
338
+ path.to_path_buf()
339
+ }
340
+
341
+ fn normalize_path(path: &Path) -> PathBuf {
342
+ let mut normalized = PathBuf::new();
343
+ for component in path.components() {
344
+ match component {
345
+ Component::CurDir => {}
346
+ Component::ParentDir => {
347
+ normalized.pop();
348
+ }
349
+ _ => normalized.push(component),
350
+ }
351
+ }
352
+ normalized
353
+ }
354
+
355
+ #[cfg(test)]
356
+ mod tests {
357
+ use super::*;
358
+
359
+ #[test]
360
+ fn blocks_destructive_shell_commands() {
361
+ let result = classify_shell_command("git reset --hard HEAD");
362
+ assert_eq!(result.tier, SafetyTier::Blocked);
363
+ assert_eq!(result.reason, "destructive git reset");
364
+ }
365
+
366
+ #[test]
367
+ fn shell_commands_require_approval_by_default() {
368
+ let result = classify_shell_command("git status --short");
369
+ assert_eq!(result.tier, SafetyTier::Approval);
370
+ assert_eq!(result.mode, ShellCommandMode::ReadOnly);
371
+ }
372
+
373
+ #[test]
374
+ fn classifies_test_and_network_shell_modes() {
375
+ assert_eq!(
376
+ classify_shell_command("cargo test -p mint-core").mode,
377
+ ShellCommandMode::Test
378
+ );
379
+ assert_eq!(
380
+ classify_shell_command("npm install").mode,
381
+ ShellCommandMode::Network
382
+ );
383
+ }
384
+
385
+ #[test]
386
+ fn shell_mode_policy_reads_config_allowlist() {
387
+ let mut config = MintConfig::default();
388
+ config.extra.insert(
389
+ "allowedShellModes".to_string(),
390
+ serde_json::json!(["readOnly", "test"]),
391
+ );
392
+ assert!(shell_mode_allowed(&config, ShellCommandMode::ReadOnly));
393
+ assert!(shell_mode_allowed(&config, ShellCommandMode::Test));
394
+ assert!(!shell_mode_allowed(&config, ShellCommandMode::Network));
395
+ assert!(!shell_mode_allowed(&config, ShellCommandMode::Mutating));
396
+ }
397
+
398
+ #[test]
399
+ fn blocks_sensitive_file_names() {
400
+ let config = MintConfig::default();
401
+ let result = assert_path_capability(Path::new(".env"), Capability::Read, &config);
402
+ assert!(matches!(result, Err(SafetyError::BlockedFileName { .. })));
403
+ }
404
+
405
+ #[test]
406
+ fn denies_paths_outside_configured_roots() {
407
+ let config = MintConfig {
408
+ allowed_read_paths: vec![PathBuf::from("/tmp/mint")],
409
+ blocked_paths: vec![],
410
+ ..MintConfig::default()
411
+ };
412
+ let result =
413
+ assert_path_capability(Path::new("/var/lib/private"), Capability::Read, &config);
414
+ assert!(matches!(result, Err(SafetyError::PathDenied { .. })));
415
+ }
416
+ }
@@ -0,0 +1,254 @@
1
+ use std::{
2
+ cmp::Ordering,
3
+ fs,
4
+ path::{Path, PathBuf},
5
+ };
6
+
7
+ use serde::{Deserialize, Serialize};
8
+ use serde_json::{Value, json};
9
+ use sha2::{Digest, Sha256};
10
+ use thiserror::Error;
11
+
12
+ use crate::{CodeInspectionError, MintConfig, list_code_files};
13
+
14
+ const EMBEDDING_MODEL: &str = "gemini-embedding-001";
15
+ const MAX_CHARS: usize = 1800;
16
+
17
+ #[derive(Debug, Error)]
18
+ pub enum SemanticError {
19
+ #[error(transparent)]
20
+ Inspect(#[from] CodeInspectionError),
21
+ #[error("Gemini API key is required for semantic code embeddings")]
22
+ MissingApiKey,
23
+ #[error("unable to read semantic index {path}: {source}")]
24
+ Read {
25
+ path: PathBuf,
26
+ source: std::io::Error,
27
+ },
28
+ #[error("unable to write semantic index {path}: {source}")]
29
+ Write {
30
+ path: PathBuf,
31
+ source: std::io::Error,
32
+ },
33
+ #[error("unable to parse semantic index {path}: {source}")]
34
+ Parse {
35
+ path: PathBuf,
36
+ source: serde_json::Error,
37
+ },
38
+ #[error("semantic embedding request failed: {0}")]
39
+ Request(#[from] reqwest::Error),
40
+ #[error("semantic embedding response did not contain an embedding")]
41
+ MissingEmbedding,
42
+ }
43
+
44
+ #[derive(Debug, Clone, Deserialize, Serialize)]
45
+ #[serde(rename_all = "camelCase")]
46
+ pub struct SemanticChunk {
47
+ pub file: PathBuf,
48
+ pub start_line: usize,
49
+ pub end_line: usize,
50
+ pub text: String,
51
+ pub embedding: Vec<f64>,
52
+ }
53
+
54
+ #[derive(Debug, Clone, Deserialize, Serialize)]
55
+ #[serde(rename_all = "camelCase")]
56
+ pub struct SemanticIndex {
57
+ pub root: PathBuf,
58
+ pub model: String,
59
+ pub file_count: usize,
60
+ pub chunk_count: usize,
61
+ pub chunks: Vec<SemanticChunk>,
62
+ pub store_path: PathBuf,
63
+ }
64
+
65
+ #[derive(Debug, Clone, Serialize)]
66
+ #[serde(rename_all = "camelCase")]
67
+ pub struct SemanticHit {
68
+ pub file: PathBuf,
69
+ pub start_line: usize,
70
+ pub end_line: usize,
71
+ pub score: f64,
72
+ pub text: String,
73
+ }
74
+
75
+ pub async fn index_semantic_code(
76
+ root: &Path,
77
+ config: &MintConfig,
78
+ ) -> Result<SemanticIndex, SemanticError> {
79
+ let root = fs::canonicalize(root).map_err(|source| SemanticError::Read {
80
+ path: root.to_path_buf(),
81
+ source,
82
+ })?;
83
+ let files = list_code_files(&root, usize::MAX, config)?;
84
+ let mut chunks = Vec::new();
85
+ for file in &files {
86
+ if file.size > 512 * 1024 || !is_source_file(&file.path) {
87
+ continue;
88
+ }
89
+ let Ok(content) = fs::read_to_string(&file.path) else {
90
+ continue;
91
+ };
92
+ for (start_line, end_line, text) in chunk_text(&content) {
93
+ chunks.push(SemanticChunk {
94
+ file: file.path.clone(),
95
+ start_line,
96
+ end_line,
97
+ embedding: embed_text(config, &text).await?,
98
+ text,
99
+ });
100
+ }
101
+ }
102
+ let store_path = semantic_store_path(&root)?;
103
+ let index = SemanticIndex {
104
+ root,
105
+ model: EMBEDDING_MODEL.into(),
106
+ file_count: files.len(),
107
+ chunk_count: chunks.len(),
108
+ chunks,
109
+ store_path: store_path.clone(),
110
+ };
111
+ if let Some(directory) = store_path.parent() {
112
+ fs::create_dir_all(directory).map_err(|source| SemanticError::Write {
113
+ path: directory.into(),
114
+ source,
115
+ })?;
116
+ }
117
+ fs::write(&store_path, serde_json::to_string_pretty(&index).unwrap()).map_err(|source| {
118
+ SemanticError::Write {
119
+ path: store_path,
120
+ source,
121
+ }
122
+ })?;
123
+ Ok(index)
124
+ }
125
+
126
+ pub async fn search_semantic_code(
127
+ root: &Path,
128
+ query: &str,
129
+ limit: usize,
130
+ config: &MintConfig,
131
+ ) -> Result<Vec<SemanticHit>, SemanticError> {
132
+ let root = fs::canonicalize(root).map_err(|source| SemanticError::Read {
133
+ path: root.to_path_buf(),
134
+ source,
135
+ })?;
136
+ let path = semantic_store_path(&root)?;
137
+ let raw = fs::read_to_string(&path).map_err(|source| SemanticError::Read {
138
+ path: path.clone(),
139
+ source,
140
+ })?;
141
+ let index: SemanticIndex =
142
+ serde_json::from_str(&raw).map_err(|source| SemanticError::Parse { path, source })?;
143
+ let query = embed_text(config, query).await?;
144
+ let mut hits = index
145
+ .chunks
146
+ .into_iter()
147
+ .map(|chunk| SemanticHit {
148
+ score: cosine_similarity(&query, &chunk.embedding),
149
+ file: chunk.file,
150
+ start_line: chunk.start_line,
151
+ end_line: chunk.end_line,
152
+ text: chunk.text,
153
+ })
154
+ .collect::<Vec<_>>();
155
+ hits.sort_by(|left, right| {
156
+ right
157
+ .score
158
+ .partial_cmp(&left.score)
159
+ .unwrap_or(Ordering::Equal)
160
+ });
161
+ hits.truncate(limit.max(1));
162
+ Ok(hits)
163
+ }
164
+
165
+ fn chunk_text(content: &str) -> Vec<(usize, usize, String)> {
166
+ let lines = content.lines().collect::<Vec<_>>();
167
+ let mut chunks = Vec::new();
168
+ let mut start = 0;
169
+ while start < lines.len() {
170
+ let mut end = start;
171
+ let mut chars = 0;
172
+ while end < lines.len() && (end == start || chars + lines[end].len() + 1 <= MAX_CHARS) {
173
+ chars += lines[end].len() + 1;
174
+ end += 1;
175
+ }
176
+ chunks.push((start + 1, end, lines[start..end].join("\n")));
177
+ start = end;
178
+ }
179
+ chunks
180
+ }
181
+
182
+ async fn embed_text(config: &MintConfig, text: &str) -> Result<Vec<f64>, SemanticError> {
183
+ let key = if config.api_key.trim().is_empty() {
184
+ std::env::var("GEMINI_API_KEY").unwrap_or_default()
185
+ } else {
186
+ config.api_key.clone()
187
+ };
188
+ if key.trim().is_empty() {
189
+ return Err(SemanticError::MissingApiKey);
190
+ }
191
+ let value: Value = crate::HTTP_CLIENT.clone()
192
+ .post(format!(
193
+ "https://generativelanguage.googleapis.com/v1beta/models/{EMBEDDING_MODEL}:embedContent?key={key}"
194
+ ))
195
+ .json(&json!({ "content": { "parts": [{ "text": text }] } }))
196
+ .send()
197
+ .await?
198
+ .error_for_status()?
199
+ .json()
200
+ .await?;
201
+ value["embedding"]["values"]
202
+ .as_array()
203
+ .map(|values| values.iter().filter_map(Value::as_f64).collect())
204
+ .filter(|values: &Vec<f64>| !values.is_empty())
205
+ .ok_or(SemanticError::MissingEmbedding)
206
+ }
207
+
208
+ fn semantic_store_path(root: &Path) -> Result<PathBuf, SemanticError> {
209
+ let hash = format!("{:x}", Sha256::digest(root.to_string_lossy().as_bytes()));
210
+ Ok(dirs::config_dir()
211
+ .unwrap_or_else(|| PathBuf::from("."))
212
+ .join("mint")
213
+ .join("semantic-code")
214
+ .join(format!("{}.json", &hash[..16])))
215
+ }
216
+
217
+ fn is_source_file(path: &Path) -> bool {
218
+ path.extension()
219
+ .and_then(|value| value.to_str())
220
+ .is_some_and(|extension| matches!(extension, "rs" | "js" | "jsx" | "ts" | "tsx" | "py"))
221
+ }
222
+
223
+ fn cosine_similarity(left: &[f64], right: &[f64]) -> f64 {
224
+ let mut dot = 0.0;
225
+ let mut norm_left = 0.0;
226
+ let mut norm_right = 0.0;
227
+ for (left, right) in left.iter().zip(right) {
228
+ dot += left * right;
229
+ norm_left += left * left;
230
+ norm_right += right * right;
231
+ }
232
+ if norm_left == 0.0 || norm_right == 0.0 {
233
+ 0.0
234
+ } else {
235
+ dot / (norm_left.sqrt() * norm_right.sqrt())
236
+ }
237
+ }
238
+
239
+ #[cfg(test)]
240
+ mod tests {
241
+ use super::*;
242
+
243
+ #[test]
244
+ fn chunks_large_source_text() {
245
+ let chunks = chunk_text(&format!("{}\n{}", "a".repeat(1700), "b".repeat(300)));
246
+ assert_eq!(chunks.len(), 2);
247
+ }
248
+
249
+ #[test]
250
+ fn computes_cosine_similarity() {
251
+ assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]), 1.0);
252
+ assert_eq!(cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]), 0.0);
253
+ }
254
+ }