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,419 @@
1
+ use std::collections::HashMap;
2
+ use std::path::PathBuf;
3
+ use std::process::Stdio;
4
+ use std::sync::Arc;
5
+ use std::time::Instant;
6
+
7
+ use async_trait::async_trait;
8
+ use chrono::Utc;
9
+ use hajimi_claw_policy::{PolicyDecision, PolicyEngine};
10
+ use hajimi_claw_types::{
11
+ ClawError, ClawResult, ExecRequest, ExecResult, Executor, SessionHandle, SessionId,
12
+ SessionOpenRequest,
13
+ };
14
+ use tokio::process::Command;
15
+ use tokio::sync::Mutex;
16
+ use tokio::time::{Duration, timeout};
17
+
18
+ #[derive(Debug, Clone, Copy)]
19
+ pub enum PlatformMode {
20
+ Unix,
21
+ WindowsSafe,
22
+ WindowsElevated,
23
+ }
24
+
25
+ #[derive(Debug, Clone)]
26
+ struct SessionState {
27
+ handle: SessionHandle,
28
+ env_allowlist: Vec<String>,
29
+ history: Vec<String>,
30
+ }
31
+
32
+ pub struct LocalExecutor {
33
+ policy: Arc<PolicyEngine>,
34
+ mode: PlatformMode,
35
+ sessions: Mutex<HashMap<SessionId, SessionState>>,
36
+ }
37
+
38
+ impl LocalExecutor {
39
+ pub fn new(policy: Arc<PolicyEngine>, mode: PlatformMode) -> Self {
40
+ Self {
41
+ policy,
42
+ mode,
43
+ sessions: Mutex::new(HashMap::new()),
44
+ }
45
+ }
46
+
47
+ pub fn policy(&self) -> &Arc<PolicyEngine> {
48
+ &self.policy
49
+ }
50
+
51
+ async fn run_checked(&self, req: ExecRequest) -> ClawResult<ExecResult> {
52
+ match self.policy.evaluate_exec(&req) {
53
+ PolicyDecision::Allow { .. } => self.spawn(req).await,
54
+ PolicyDecision::RequiresApproval(approval) => Err(ClawError::ApprovalRequired(
55
+ format!("{} [{}]", approval.reason, approval.request_id),
56
+ )),
57
+ PolicyDecision::Deny(reason) => Err(ClawError::AccessDenied(reason)),
58
+ }
59
+ }
60
+
61
+ async fn spawn(&self, req: ExecRequest) -> ClawResult<ExecResult> {
62
+ if matches!(self.mode, PlatformMode::WindowsSafe) && !self.policy.is_elevated() {
63
+ self.validate_windows_safe_request(&req)?;
64
+ }
65
+
66
+ let start = Instant::now();
67
+ let mut command = build_command(&req, self.mode)?;
68
+ let child = command
69
+ .spawn()
70
+ .map_err(|err| ClawError::Backend(err.to_string()))?;
71
+
72
+ #[cfg(windows)]
73
+ let _job = windows_job::attach_kill_on_close(&child).ok();
74
+
75
+ let output = timeout(
76
+ Duration::from_secs(req.timeout_secs),
77
+ child.wait_with_output(),
78
+ )
79
+ .await
80
+ .map_err(|_| ClawError::Backend("command timed out".into()))?
81
+ .map_err(|err| ClawError::Backend(err.to_string()))?;
82
+
83
+ let (stdout, stderr, truncated) =
84
+ truncate_output(output.stdout, output.stderr, req.max_output_bytes);
85
+
86
+ Ok(ExecResult {
87
+ exit_code: output.status.code(),
88
+ stdout,
89
+ stderr,
90
+ duration_ms: start.elapsed().as_millis(),
91
+ truncated,
92
+ })
93
+ }
94
+
95
+ fn validate_windows_safe_request(&self, req: &ExecRequest) -> ClawResult<()> {
96
+ if !self.policy.windows_command_allowed(&req.command) {
97
+ return Err(ClawError::AccessDenied(format!(
98
+ "command is not in the Windows safe allowlist: {}",
99
+ req.command
100
+ )));
101
+ }
102
+ if let Some(cwd) = &req.cwd {
103
+ if !self.policy.is_allowed_workdir(cwd) {
104
+ return Err(ClawError::AccessDenied(format!(
105
+ "working directory is not allowed: {}",
106
+ cwd.display()
107
+ )));
108
+ }
109
+ }
110
+ Ok(())
111
+ }
112
+ }
113
+
114
+ #[async_trait]
115
+ impl Executor for LocalExecutor {
116
+ async fn run_once(&self, req: ExecRequest) -> ClawResult<ExecResult> {
117
+ self.run_checked(req).await
118
+ }
119
+
120
+ async fn open_session(&self, req: SessionOpenRequest) -> ClawResult<SessionHandle> {
121
+ let cwd = req
122
+ .cwd
123
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
124
+ if !self.policy.is_allowed_workdir(&cwd) {
125
+ return Err(ClawError::AccessDenied(format!(
126
+ "working directory is not allowed: {}",
127
+ cwd.display()
128
+ )));
129
+ }
130
+
131
+ let handle = SessionHandle {
132
+ id: SessionId::new(),
133
+ name: req
134
+ .name
135
+ .unwrap_or_else(|| format!("shell-{}", Utc::now().timestamp())),
136
+ cwd,
137
+ created_at: Utc::now(),
138
+ last_used_at: Utc::now(),
139
+ };
140
+ let state = SessionState {
141
+ handle: handle.clone(),
142
+ env_allowlist: req.env_allowlist,
143
+ history: Vec::new(),
144
+ };
145
+ self.sessions.lock().await.insert(handle.id, state);
146
+ Ok(handle)
147
+ }
148
+
149
+ async fn run_in_session(&self, id: SessionId, mut req: ExecRequest) -> ClawResult<ExecResult> {
150
+ let mut sessions = self.sessions.lock().await;
151
+ let state = sessions
152
+ .get_mut(&id)
153
+ .ok_or_else(|| ClawError::NotFound(format!("session not found: {id}")))?;
154
+
155
+ if req.command.eq_ignore_ascii_case("cd") {
156
+ let target = req
157
+ .args
158
+ .first()
159
+ .map(PathBuf::from)
160
+ .ok_or_else(|| ClawError::InvalidRequest("cd requires a target path".into()))?;
161
+ let next = if target.is_absolute() {
162
+ target
163
+ } else {
164
+ state.handle.cwd.join(target)
165
+ };
166
+ if !self.policy.is_allowed_workdir(&next) {
167
+ return Err(ClawError::AccessDenied(format!(
168
+ "working directory is not allowed: {}",
169
+ next.display()
170
+ )));
171
+ }
172
+ state.handle.cwd = next.clone();
173
+ state.handle.last_used_at = Utc::now();
174
+ state.history.push(format!("cd {}", next.display()));
175
+ return Ok(ExecResult {
176
+ exit_code: Some(0),
177
+ stdout: format!("cwd -> {}", next.display()),
178
+ stderr: String::new(),
179
+ duration_ms: 0,
180
+ truncated: false,
181
+ });
182
+ }
183
+
184
+ req.cwd = Some(state.handle.cwd.clone());
185
+ if req.env_allowlist.is_empty() {
186
+ req.env_allowlist = state.env_allowlist.clone();
187
+ }
188
+ state.history.push(req.summary());
189
+ state.handle.last_used_at = Utc::now();
190
+ drop(sessions);
191
+
192
+ self.run_checked(req).await
193
+ }
194
+
195
+ async fn close_session(&self, id: SessionId) -> ClawResult<()> {
196
+ self.sessions
197
+ .lock()
198
+ .await
199
+ .remove(&id)
200
+ .ok_or_else(|| ClawError::NotFound(format!("session not found: {id}")))?;
201
+ Ok(())
202
+ }
203
+ }
204
+
205
+ fn build_command(req: &ExecRequest, mode: PlatformMode) -> ClawResult<Command> {
206
+ let mut command = if matches!(mode, PlatformMode::Unix) {
207
+ let mut cmd = Command::new(&req.command);
208
+ cmd.args(&req.args);
209
+ cmd
210
+ } else {
211
+ let program = req.command.to_ascii_lowercase();
212
+ if matches!(
213
+ program.as_str(),
214
+ "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
215
+ ) {
216
+ let mut cmd = Command::new(&req.command);
217
+ cmd.arg("-NoProfile").args(&req.args);
218
+ cmd
219
+ } else {
220
+ let mut cmd = Command::new(&req.command);
221
+ cmd.args(&req.args);
222
+ cmd
223
+ }
224
+ };
225
+
226
+ command.kill_on_drop(true);
227
+ command.stdin(Stdio::null());
228
+ command.stdout(Stdio::piped());
229
+ command.stderr(Stdio::piped());
230
+ command.env_clear();
231
+
232
+ for key in &req.env_allowlist {
233
+ if let Ok(value) = std::env::var(key) {
234
+ command.env(key, value);
235
+ }
236
+ }
237
+
238
+ if let Some(cwd) = &req.cwd {
239
+ command.current_dir(cwd);
240
+ }
241
+
242
+ #[cfg(unix)]
243
+ {
244
+ command.env("PATH", std::env::var("PATH").unwrap_or_default());
245
+ }
246
+ #[cfg(windows)]
247
+ {
248
+ command.env("PATH", std::env::var("PATH").unwrap_or_default());
249
+ command.creation_flags(0x08000000);
250
+ }
251
+
252
+ Ok(command)
253
+ }
254
+
255
+ fn truncate_output(
256
+ stdout: Vec<u8>,
257
+ stderr: Vec<u8>,
258
+ max_output_bytes: usize,
259
+ ) -> (String, String, bool) {
260
+ let mut truncated = false;
261
+ let max_each = max_output_bytes / 2;
262
+
263
+ let stdout = if stdout.len() > max_each {
264
+ truncated = true;
265
+ lossy_tail(stdout, max_each)
266
+ } else {
267
+ String::from_utf8_lossy(&stdout).into_owned()
268
+ };
269
+ let stderr = if stderr.len() > max_each {
270
+ truncated = true;
271
+ lossy_tail(stderr, max_each)
272
+ } else {
273
+ String::from_utf8_lossy(&stderr).into_owned()
274
+ };
275
+
276
+ (stdout, stderr, truncated)
277
+ }
278
+
279
+ fn lossy_tail(bytes: Vec<u8>, limit: usize) -> String {
280
+ let slice = if bytes.len() > limit {
281
+ &bytes[bytes.len() - limit..]
282
+ } else {
283
+ &bytes[..]
284
+ };
285
+ format!("...[truncated]\n{}", String::from_utf8_lossy(slice))
286
+ }
287
+
288
+ #[cfg(windows)]
289
+ mod windows_job {
290
+ use tokio::process::Child;
291
+ use windows::Win32::Foundation::{CloseHandle, HANDLE};
292
+ use windows::Win32::System::JobObjects::{
293
+ AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
294
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
295
+ SetInformationJobObject,
296
+ };
297
+
298
+ pub struct KillOnCloseJob(*mut core::ffi::c_void);
299
+
300
+ unsafe impl Send for KillOnCloseJob {}
301
+ unsafe impl Sync for KillOnCloseJob {}
302
+
303
+ impl Drop for KillOnCloseJob {
304
+ fn drop(&mut self) {
305
+ unsafe {
306
+ let _ = CloseHandle(HANDLE(self.0));
307
+ }
308
+ }
309
+ }
310
+
311
+ pub fn attach_kill_on_close(child: &Child) -> anyhow::Result<KillOnCloseJob> {
312
+ unsafe {
313
+ let job = CreateJobObjectW(None, None)?;
314
+ let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
315
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
316
+ SetInformationJobObject(
317
+ job,
318
+ JobObjectExtendedLimitInformation,
319
+ &info as *const _ as *const _,
320
+ std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
321
+ )?;
322
+
323
+ let raw = child
324
+ .raw_handle()
325
+ .ok_or_else(|| anyhow::anyhow!("child does not expose a raw handle"))?;
326
+ let process = HANDLE(raw);
327
+ AssignProcessToJobObject(job, process)?;
328
+ Ok(KillOnCloseJob(job.0))
329
+ }
330
+ }
331
+ }
332
+
333
+ #[cfg(test)]
334
+ mod tests {
335
+ use std::path::PathBuf;
336
+ use std::sync::Arc;
337
+
338
+ use hajimi_claw_policy::{PolicyConfig, PolicyEngine};
339
+ use hajimi_claw_types::{ExecRequest, Executor, SessionOpenRequest};
340
+
341
+ use super::{LocalExecutor, PlatformMode};
342
+
343
+ fn executor() -> LocalExecutor {
344
+ let mut config = PolicyConfig::default();
345
+ config.allowed_workdirs = vec![std::env::current_dir().unwrap(), std::env::temp_dir()];
346
+ LocalExecutor::new(
347
+ Arc::new(PolicyEngine::new(config)),
348
+ PlatformMode::WindowsSafe,
349
+ )
350
+ }
351
+
352
+ #[tokio::test]
353
+ async fn truncates_output() {
354
+ let executor = executor();
355
+ let req = ExecRequest {
356
+ command: "cmd".into(),
357
+ args: vec!["/C".into(), "for /L %i in (1,1,300) do @echo line".into()],
358
+ cwd: Some(std::env::temp_dir()),
359
+ env_allowlist: vec![],
360
+ timeout_secs: 10,
361
+ max_output_bytes: 128,
362
+ requires_tty: false,
363
+ };
364
+
365
+ let result = executor.run_once(req).await.expect("command succeeds");
366
+ assert!(result.truncated);
367
+ }
368
+
369
+ #[tokio::test]
370
+ async fn session_preserves_cwd() {
371
+ let executor = executor();
372
+ let dir = tempfile::tempdir().unwrap();
373
+ let session = executor
374
+ .open_session(SessionOpenRequest {
375
+ name: None,
376
+ cwd: Some(PathBuf::from(dir.path())),
377
+ env_allowlist: vec![],
378
+ })
379
+ .await
380
+ .expect("session opens");
381
+ executor
382
+ .run_in_session(
383
+ session.id,
384
+ ExecRequest {
385
+ command: "cd".into(),
386
+ args: vec![".".into()],
387
+ cwd: None,
388
+ env_allowlist: vec![],
389
+ timeout_secs: 10,
390
+ max_output_bytes: 128,
391
+ requires_tty: false,
392
+ },
393
+ )
394
+ .await
395
+ .expect("cd succeeds");
396
+
397
+ let output = executor
398
+ .run_in_session(
399
+ session.id,
400
+ ExecRequest {
401
+ command: "cmd".into(),
402
+ args: vec!["/C".into(), "cd".into()],
403
+ cwd: None,
404
+ env_allowlist: vec![],
405
+ timeout_secs: 10,
406
+ max_output_bytes: 256,
407
+ requires_tty: false,
408
+ },
409
+ )
410
+ .await
411
+ .expect("pwd succeeds");
412
+ assert!(
413
+ output
414
+ .stdout
415
+ .to_ascii_lowercase()
416
+ .contains(&dir.path().display().to_string().to_ascii_lowercase())
417
+ );
418
+ }
419
+ }
@@ -0,0 +1,27 @@
1
+ [package]
2
+ name = "hajimi-claw-gateway"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ authors.workspace = true
7
+
8
+ [dependencies]
9
+ anyhow.workspace = true
10
+ chrono.workspace = true
11
+ async-trait.workspace = true
12
+ hajimi-claw-agent = { path = "../hajimi-claw-agent" }
13
+ hajimi-claw-llm = { path = "../hajimi-claw-llm" }
14
+ hajimi-claw-policy = { path = "../hajimi-claw-policy" }
15
+ hajimi-claw-store = { path = "../hajimi-claw-store" }
16
+ hajimi-claw-types = { path = "../hajimi-claw-types" }
17
+ reqwest.workspace = true
18
+ serde.workspace = true
19
+ thiserror.workspace = true
20
+
21
+ [dev-dependencies]
22
+ anyhow.workspace = true
23
+ hajimi-claw-exec = { path = "../hajimi-claw-exec" }
24
+ hajimi-claw-store = { path = "../hajimi-claw-store" }
25
+ hajimi-claw-tools = { path = "../hajimi-claw-tools" }
26
+ tempfile.workspace = true
27
+ tokio.workspace = true