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.
- package/Cargo.lock +2602 -0
- package/Cargo.toml +57 -0
- package/README.md +73 -0
- package/bin/hajimi-claw.js +28 -0
- package/config.example.toml +32 -0
- package/crates/hajimi-claw-agent/Cargo.toml +25 -0
- package/crates/hajimi-claw-agent/src/lib.rs +351 -0
- package/crates/hajimi-claw-bot/Cargo.toml +18 -0
- package/crates/hajimi-claw-bot/src/lib.rs +305 -0
- package/crates/hajimi-claw-daemon/Cargo.toml +24 -0
- package/crates/hajimi-claw-daemon/src/lib.rs +173 -0
- package/crates/hajimi-claw-exec/Cargo.toml +21 -0
- package/crates/hajimi-claw-exec/src/lib.rs +419 -0
- package/crates/hajimi-claw-gateway/Cargo.toml +27 -0
- package/crates/hajimi-claw-gateway/src/lib.rs +747 -0
- package/crates/hajimi-claw-llm/Cargo.toml +19 -0
- package/crates/hajimi-claw-llm/src/lib.rs +367 -0
- package/crates/hajimi-claw-policy/Cargo.toml +14 -0
- package/crates/hajimi-claw-policy/src/lib.rs +381 -0
- package/crates/hajimi-claw-store/Cargo.toml +17 -0
- package/crates/hajimi-claw-store/src/lib.rs +730 -0
- package/crates/hajimi-claw-tools/Cargo.toml +21 -0
- package/crates/hajimi-claw-tools/src/lib.rs +758 -0
- package/crates/hajimi-claw-types/Cargo.toml +16 -0
- package/crates/hajimi-claw-types/src/lib.rs +300 -0
- package/package.json +26 -0
- package/scripts/npm-install.js +45 -0
- package/src/main.rs +4 -0
|
@@ -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
|