hajimi-claw 0.1.0

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.
@@ -0,0 +1,381 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::path::{Path, PathBuf};
3
+ use std::sync::RwLock;
4
+
5
+ use chrono::{DateTime, Duration, Utc};
6
+ use hajimi_claw_types::{ApprovalId, ApprovalRequest, ExecRequest, PolicyMode, RiskLevel};
7
+ use regex::Regex;
8
+ use serde::{Deserialize, Serialize};
9
+
10
+ #[derive(Debug, Clone, Serialize, Deserialize)]
11
+ pub struct PolicyConfig {
12
+ pub admin_user_id: i64,
13
+ pub admin_chat_id: i64,
14
+ pub allowed_workdirs: Vec<PathBuf>,
15
+ pub writable_workdirs: Vec<PathBuf>,
16
+ pub windows_safe_allowlist: Vec<String>,
17
+ pub guarded_patterns: Vec<String>,
18
+ pub dangerous_patterns: Vec<String>,
19
+ pub max_timeout_secs: u64,
20
+ pub max_output_bytes: usize,
21
+ pub session_idle_timeout_secs: u64,
22
+ }
23
+
24
+ impl Default for PolicyConfig {
25
+ fn default() -> Self {
26
+ Self {
27
+ admin_user_id: 0,
28
+ admin_chat_id: 0,
29
+ allowed_workdirs: vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))],
30
+ writable_workdirs: vec![std::env::temp_dir()],
31
+ windows_safe_allowlist: vec![
32
+ "cmd".into(),
33
+ "cmd.exe".into(),
34
+ "powershell".into(),
35
+ "powershell.exe".into(),
36
+ "pwsh".into(),
37
+ "pwsh.exe".into(),
38
+ "docker".into(),
39
+ "docker.exe".into(),
40
+ "systemctl".into(),
41
+ "type".into(),
42
+ "cat".into(),
43
+ ],
44
+ guarded_patterns: vec![
45
+ r"\b(systemctl|docker)\s+(restart|stop|rm)\b".into(),
46
+ r"\b(chmod|chown|mv|cp)\b".into(),
47
+ ],
48
+ dangerous_patterns: vec![
49
+ r"\b(rm|del)\s+(-rf|/s|/q|/f)\b".into(),
50
+ r"\b(sudo|su|passwd|shutdown|reboot)\b".into(),
51
+ r">\s*/".into(),
52
+ r"format\s+".into(),
53
+ ],
54
+ max_timeout_secs: 120,
55
+ max_output_bytes: 32 * 1024,
56
+ session_idle_timeout_secs: 1800,
57
+ }
58
+ }
59
+ }
60
+
61
+ #[derive(Debug, Clone)]
62
+ pub enum PolicyDecision {
63
+ Allow { risk: RiskLevel, mode: PolicyMode },
64
+ RequiresApproval(ApprovalRequest),
65
+ Deny(String),
66
+ }
67
+
68
+ #[derive(Debug, Clone)]
69
+ pub struct ElevationLease {
70
+ pub reason: String,
71
+ pub expires_at: DateTime<Utc>,
72
+ }
73
+
74
+ #[derive(Debug, Default)]
75
+ struct PolicyState {
76
+ approvals: HashMap<ApprovalId, ApprovalRequest>,
77
+ elevated_lease: Option<ElevationLease>,
78
+ }
79
+
80
+ pub struct PolicyEngine {
81
+ config: PolicyConfig,
82
+ guarded_patterns: Vec<Regex>,
83
+ dangerous_patterns: Vec<Regex>,
84
+ state: RwLock<PolicyState>,
85
+ }
86
+
87
+ impl PolicyEngine {
88
+ pub fn new(config: PolicyConfig) -> Self {
89
+ Self {
90
+ guarded_patterns: compile_patterns(&config.guarded_patterns),
91
+ dangerous_patterns: compile_patterns(&config.dangerous_patterns),
92
+ config,
93
+ state: RwLock::new(PolicyState::default()),
94
+ }
95
+ }
96
+
97
+ pub fn config(&self) -> &PolicyConfig {
98
+ &self.config
99
+ }
100
+
101
+ pub fn authorize_telegram_actor(&self, user_id: i64, chat_id: i64) -> bool {
102
+ self.config.admin_user_id == user_id && self.config.admin_chat_id == chat_id
103
+ }
104
+
105
+ pub fn current_mode(&self) -> PolicyMode {
106
+ let state = self.state.read().expect("policy state poisoned");
107
+ match &state.elevated_lease {
108
+ Some(lease) if lease.expires_at > Utc::now() => PolicyMode::ElevatedLease,
109
+ _ if !state.approvals.is_empty() => PolicyMode::ApprovalPending,
110
+ _ => PolicyMode::Normal,
111
+ }
112
+ }
113
+
114
+ pub fn is_elevated(&self) -> bool {
115
+ matches!(self.current_mode(), PolicyMode::ElevatedLease)
116
+ }
117
+
118
+ pub fn request_elevation(&self, minutes: i64, reason: String) -> ApprovalRequest {
119
+ let request = ApprovalRequest {
120
+ request_id: ApprovalId::new(),
121
+ reason,
122
+ risk_level: RiskLevel::Dangerous,
123
+ command_preview: format!("elevated lease for {} minute(s)", minutes),
124
+ cwd: None,
125
+ expires_at: Utc::now() + Duration::minutes(10),
126
+ };
127
+ self.state
128
+ .write()
129
+ .expect("policy state poisoned")
130
+ .approvals
131
+ .insert(request.request_id, request.clone());
132
+ request
133
+ }
134
+
135
+ pub fn approve(&self, request_id: ApprovalId) -> Option<ApprovalRequest> {
136
+ let mut state = self.state.write().expect("policy state poisoned");
137
+ let request = state.approvals.remove(&request_id)?;
138
+ if request.command_preview.starts_with("elevated lease") {
139
+ state.elevated_lease = Some(ElevationLease {
140
+ reason: request.reason.clone(),
141
+ expires_at: Utc::now() + Duration::minutes(10),
142
+ });
143
+ }
144
+ Some(request)
145
+ }
146
+
147
+ pub fn reject(&self, request_id: ApprovalId) -> Option<ApprovalRequest> {
148
+ self.state
149
+ .write()
150
+ .expect("policy state poisoned")
151
+ .approvals
152
+ .remove(&request_id)
153
+ }
154
+
155
+ pub fn stop_elevation(&self) {
156
+ self.state
157
+ .write()
158
+ .expect("policy state poisoned")
159
+ .elevated_lease = None;
160
+ }
161
+
162
+ pub fn expire_lease_if_needed(&self) {
163
+ let mut state = self.state.write().expect("policy state poisoned");
164
+ if state
165
+ .elevated_lease
166
+ .as_ref()
167
+ .is_some_and(|lease| lease.expires_at <= Utc::now())
168
+ {
169
+ state.elevated_lease = None;
170
+ }
171
+ }
172
+
173
+ pub fn evaluate_exec(&self, req: &ExecRequest) -> PolicyDecision {
174
+ self.expire_lease_if_needed();
175
+
176
+ if req.timeout_secs == 0 || req.timeout_secs > self.config.max_timeout_secs {
177
+ return PolicyDecision::Deny(format!(
178
+ "timeout must be between 1 and {} seconds",
179
+ self.config.max_timeout_secs
180
+ ));
181
+ }
182
+
183
+ if req.max_output_bytes == 0 || req.max_output_bytes > self.config.max_output_bytes {
184
+ return PolicyDecision::Deny(format!(
185
+ "max_output_bytes must be between 1 and {}",
186
+ self.config.max_output_bytes
187
+ ));
188
+ }
189
+
190
+ if let Some(cwd) = &req.cwd {
191
+ if !self.is_allowed_workdir(cwd) {
192
+ return PolicyDecision::Deny(format!(
193
+ "working directory is not allowed: {}",
194
+ cwd.display()
195
+ ));
196
+ }
197
+ }
198
+
199
+ if is_sensitive_env(&req.env_allowlist) {
200
+ return PolicyDecision::Deny("sensitive env vars cannot be inherited".into());
201
+ }
202
+
203
+ let rendered = req.summary();
204
+
205
+ if self
206
+ .dangerous_patterns
207
+ .iter()
208
+ .any(|pattern| pattern.is_match(&rendered))
209
+ {
210
+ if self.is_elevated() {
211
+ return PolicyDecision::Allow {
212
+ risk: RiskLevel::Dangerous,
213
+ mode: PolicyMode::ElevatedLease,
214
+ };
215
+ }
216
+
217
+ let approval = ApprovalRequest {
218
+ request_id: ApprovalId::new(),
219
+ reason: "dangerous command requires elevated lease".into(),
220
+ risk_level: RiskLevel::Dangerous,
221
+ command_preview: rendered,
222
+ cwd: req.cwd.clone(),
223
+ expires_at: Utc::now() + Duration::minutes(10),
224
+ };
225
+ self.state
226
+ .write()
227
+ .expect("policy state poisoned")
228
+ .approvals
229
+ .insert(approval.request_id, approval.clone());
230
+ return PolicyDecision::RequiresApproval(approval);
231
+ }
232
+
233
+ if self
234
+ .guarded_patterns
235
+ .iter()
236
+ .any(|pattern| pattern.is_match(&rendered))
237
+ {
238
+ let approval = ApprovalRequest {
239
+ request_id: ApprovalId::new(),
240
+ reason: "guarded command requires explicit approval".into(),
241
+ risk_level: RiskLevel::Guarded,
242
+ command_preview: rendered,
243
+ cwd: req.cwd.clone(),
244
+ expires_at: Utc::now() + Duration::minutes(10),
245
+ };
246
+ self.state
247
+ .write()
248
+ .expect("policy state poisoned")
249
+ .approvals
250
+ .insert(approval.request_id, approval.clone());
251
+ return PolicyDecision::RequiresApproval(approval);
252
+ }
253
+
254
+ PolicyDecision::Allow {
255
+ risk: RiskLevel::Safe,
256
+ mode: self.current_mode(),
257
+ }
258
+ }
259
+
260
+ pub fn windows_command_allowed(&self, command: &str) -> bool {
261
+ let allowlist: HashSet<_> = self
262
+ .config
263
+ .windows_safe_allowlist
264
+ .iter()
265
+ .map(|cmd| cmd.to_ascii_lowercase())
266
+ .collect();
267
+ allowlist.contains(&command.to_ascii_lowercase())
268
+ }
269
+
270
+ pub fn is_allowed_workdir(&self, path: &Path) -> bool {
271
+ normalize(path).is_some_and(|candidate| {
272
+ self.config
273
+ .allowed_workdirs
274
+ .iter()
275
+ .any(|root| path_within(&candidate, root))
276
+ })
277
+ }
278
+
279
+ pub fn is_writable_workdir(&self, path: &Path) -> bool {
280
+ normalize(path).is_some_and(|candidate| {
281
+ self.config
282
+ .writable_workdirs
283
+ .iter()
284
+ .any(|root| path_within(&candidate, root))
285
+ })
286
+ }
287
+ }
288
+
289
+ fn normalize(path: &Path) -> Option<PathBuf> {
290
+ path.canonicalize()
291
+ .ok()
292
+ .or_else(|| Some(path.to_path_buf()))
293
+ }
294
+
295
+ fn path_within(path: &Path, root: &Path) -> bool {
296
+ let root = normalize(root).unwrap_or_else(|| root.to_path_buf());
297
+ path.starts_with(&root)
298
+ }
299
+
300
+ fn is_sensitive_env(allowlist: &[String]) -> bool {
301
+ static BLOCKED: &[&str] = &[
302
+ "AWS_SECRET_ACCESS_KEY",
303
+ "OPENAI_API_KEY",
304
+ "TELEGRAM_BOT_TOKEN",
305
+ "SSH_AUTH_SOCK",
306
+ ];
307
+ allowlist.iter().any(|entry| {
308
+ BLOCKED
309
+ .iter()
310
+ .any(|blocked| blocked.eq_ignore_ascii_case(entry))
311
+ })
312
+ }
313
+
314
+ fn compile_patterns(patterns: &[String]) -> Vec<Regex> {
315
+ patterns
316
+ .iter()
317
+ .filter_map(|pattern| Regex::new(pattern).ok())
318
+ .collect()
319
+ }
320
+
321
+ #[cfg(test)]
322
+ mod tests {
323
+ use std::path::PathBuf;
324
+
325
+ use hajimi_claw_types::ExecRequest;
326
+
327
+ use super::{PolicyConfig, PolicyDecision, PolicyEngine};
328
+
329
+ fn sample_request(command: &str, args: &[&str]) -> ExecRequest {
330
+ ExecRequest {
331
+ command: command.into(),
332
+ args: args.iter().map(|item| item.to_string()).collect(),
333
+ cwd: Some(std::env::temp_dir()),
334
+ env_allowlist: vec![],
335
+ timeout_secs: 30,
336
+ max_output_bytes: 1024,
337
+ requires_tty: false,
338
+ }
339
+ }
340
+
341
+ #[test]
342
+ fn denies_disallowed_workdir() {
343
+ let engine = PolicyEngine::new(PolicyConfig {
344
+ allowed_workdirs: vec![PathBuf::from("C:/allowed")],
345
+ ..PolicyConfig::default()
346
+ });
347
+ let request = sample_request("cmd", &["/C", "echo hi"]);
348
+ assert!(matches!(
349
+ engine.evaluate_exec(&request),
350
+ PolicyDecision::Deny(_)
351
+ ));
352
+ }
353
+
354
+ #[test]
355
+ fn guarded_commands_require_approval() {
356
+ let mut config = PolicyConfig::default();
357
+ config.allowed_workdirs = vec![std::env::temp_dir()];
358
+ let engine = PolicyEngine::new(config);
359
+ let request = sample_request("systemctl", &["restart", "nginx"]);
360
+ assert!(matches!(
361
+ engine.evaluate_exec(&request),
362
+ PolicyDecision::RequiresApproval(_)
363
+ ));
364
+ }
365
+
366
+ #[test]
367
+ fn elevation_allows_dangerous_command_after_approval() {
368
+ let mut config = PolicyConfig::default();
369
+ config.allowed_workdirs = vec![std::env::temp_dir()];
370
+ let engine = PolicyEngine::new(config);
371
+ let request = sample_request("sudo", &["shutdown", "-r", "now"]);
372
+ let approval = match engine.request_elevation(10, "maintenance".into()) {
373
+ approval => approval,
374
+ };
375
+ engine.approve(approval.request_id);
376
+ assert!(matches!(
377
+ engine.evaluate_exec(&request),
378
+ PolicyDecision::Allow { .. }
379
+ ));
380
+ }
381
+ }
@@ -0,0 +1,17 @@
1
+ [package]
2
+ name = "hajimi-claw-store"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ authors.workspace = true
7
+
8
+ [dependencies]
9
+ aes-gcm.workspace = true
10
+ anyhow.workspace = true
11
+ base64.workspace = true
12
+ chrono.workspace = true
13
+ hajimi-claw-types = { path = "../hajimi-claw-types" }
14
+ rusqlite.workspace = true
15
+ serde_json.workspace = true
16
+ sha2.workspace = true
17
+ uuid.workspace = true