agentguard-local 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,384 @@
1
+ use serde_json::Value;
2
+ use std::fs;
3
+ use std::io;
4
+ use std::os::unix::fs::PermissionsExt;
5
+ use std::path::{Path, PathBuf};
6
+ use std::process::{Child, Command, Output};
7
+ use std::thread;
8
+ use std::time::{Duration, Instant};
9
+ use tempfile::{TempDir, tempdir};
10
+
11
+ struct DaemonGuard {
12
+ child: Child,
13
+ _dir: TempDir,
14
+ socket: PathBuf,
15
+ db: PathBuf,
16
+ pressure_file: PathBuf,
17
+ }
18
+
19
+ impl DaemonGuard {
20
+ fn start() -> Self {
21
+ let dir = tempdir().unwrap();
22
+ let socket = dir.path().join("agentguard.sock");
23
+ let db = dir.path().join("state.db");
24
+ let pressure_file = dir.path().join("pressure.txt");
25
+ fs::write(&pressure_file, "normal").unwrap();
26
+ let child = Command::new(bin("agentguardd"))
27
+ .arg("--socket")
28
+ .arg(&socket)
29
+ .arg("--db")
30
+ .arg(&db)
31
+ .arg("--foreground")
32
+ .env("AGENTGUARD_TEST_MODE", "1")
33
+ .env("AGENTGUARD_FAKE_PRESSURE_FILE", &pressure_file)
34
+ .spawn()
35
+ .unwrap();
36
+ let daemon = Self {
37
+ child,
38
+ _dir: dir,
39
+ socket,
40
+ db,
41
+ pressure_file,
42
+ };
43
+ wait_for(
44
+ || daemon.status_json_result().is_ok(),
45
+ Duration::from_secs(5),
46
+ )
47
+ .unwrap();
48
+ let output = daemon
49
+ .cli()
50
+ .args(["policy", "set", "recovery_window_seconds", "1"])
51
+ .output()
52
+ .unwrap();
53
+ assert_success(&output);
54
+ daemon
55
+ }
56
+
57
+ fn restart(&mut self) {
58
+ let _ = self.child.kill();
59
+ let _ = self.child.wait();
60
+ self.child = Command::new(bin("agentguardd"))
61
+ .arg("--socket")
62
+ .arg(&self.socket)
63
+ .arg("--db")
64
+ .arg(&self.db)
65
+ .arg("--foreground")
66
+ .env("AGENTGUARD_TEST_MODE", "1")
67
+ .env("AGENTGUARD_FAKE_PRESSURE_FILE", &self.pressure_file)
68
+ .spawn()
69
+ .unwrap();
70
+ wait_for(|| self.status_json_result().is_ok(), Duration::from_secs(5)).unwrap();
71
+ }
72
+
73
+ fn set_pressure(&self, pressure: &str) {
74
+ fs::write(&self.pressure_file, pressure).unwrap();
75
+ }
76
+
77
+ fn cli(&self) -> Command {
78
+ let mut command = Command::new(bin("agentguard"));
79
+ command.arg("--socket").arg(&self.socket);
80
+ command
81
+ }
82
+
83
+ fn status_json(&self) -> Value {
84
+ self.status_json_result().unwrap()
85
+ }
86
+
87
+ fn status_json_result(&self) -> Result<Value, String> {
88
+ let output = self.cli().arg("status").arg("--json").output().unwrap();
89
+ if !output.status.success() {
90
+ return Err(format!(
91
+ "status={:?}\nstdout={}\nstderr={}",
92
+ output.status.code(),
93
+ String::from_utf8_lossy(&output.stdout),
94
+ String::from_utf8_lossy(&output.stderr)
95
+ ));
96
+ }
97
+ serde_json::from_slice(&output.stdout).map_err(|err| err.to_string())
98
+ }
99
+ }
100
+
101
+ impl Drop for DaemonGuard {
102
+ fn drop(&mut self) {
103
+ let _ = self.child.kill();
104
+ let _ = self.child.wait();
105
+ }
106
+ }
107
+
108
+ #[test]
109
+ fn status_json_reports_pressure_policy_and_empty_job_state() {
110
+ let daemon = DaemonGuard::start();
111
+ let status = daemon.status_json();
112
+ assert_eq!(status["pressure"], "normal");
113
+ assert_eq!(status["policy"]["max_active_jobs"], 1);
114
+ assert!(status["jobs"].as_array().unwrap().is_empty());
115
+ }
116
+
117
+ #[test]
118
+ fn run_fake_tool_records_job_and_preserves_stdio_and_exit_code() {
119
+ let daemon = DaemonGuard::start();
120
+ let output = daemon
121
+ .cli()
122
+ .args([
123
+ "run",
124
+ "--",
125
+ "/bin/sh",
126
+ "-c",
127
+ "echo out; echo err >&2; exit 7",
128
+ ])
129
+ .output()
130
+ .unwrap();
131
+ assert_eq!(output.status.code(), Some(7));
132
+ assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "out");
133
+ assert_eq!(String::from_utf8_lossy(&output.stderr).trim(), "err");
134
+
135
+ let status = daemon.status_json();
136
+ let jobs = status["jobs"].as_array().unwrap();
137
+ assert_eq!(jobs[0]["status"], "failed");
138
+ assert_eq!(jobs[0]["exit_code"], 7);
139
+ }
140
+
141
+ #[test]
142
+ fn active_job_limit_queues_second_job_until_first_exits() {
143
+ let daemon = DaemonGuard::start();
144
+ let mut first = daemon
145
+ .cli()
146
+ .args(["run", "--", "/bin/sleep", "1"])
147
+ .spawn()
148
+ .unwrap();
149
+ wait_for(
150
+ || status_has_state(&daemon, "running"),
151
+ Duration::from_secs(5),
152
+ )
153
+ .unwrap();
154
+
155
+ let mut second = daemon
156
+ .cli()
157
+ .args(["run", "--", "/bin/sh", "-c", "echo second"])
158
+ .spawn()
159
+ .unwrap();
160
+ wait_for(
161
+ || status_has_state(&daemon, "queued"),
162
+ Duration::from_secs(5),
163
+ )
164
+ .unwrap();
165
+
166
+ assert_eq!(
167
+ wait_child(&mut first, Duration::from_secs(5)).unwrap(),
168
+ Some(0)
169
+ );
170
+ assert_eq!(
171
+ wait_child(&mut second, Duration::from_secs(5)).unwrap(),
172
+ Some(0)
173
+ );
174
+ let status = daemon.status_json();
175
+ assert!(status["jobs"].as_array().unwrap().iter().any(|job| {
176
+ job["command"] == "/bin/sh" && job["status"] == "exited" && job["exit_code"] == 0
177
+ }));
178
+ }
179
+
180
+ #[test]
181
+ fn simulated_soft_pressure_queues_then_recovery_admits_job() {
182
+ let daemon = DaemonGuard::start();
183
+ daemon.set_pressure("soft");
184
+ let mut queued = daemon
185
+ .cli()
186
+ .args(["run", "--", "/bin/sh", "-c", "echo recovered"])
187
+ .stdout(std::process::Stdio::piped())
188
+ .spawn()
189
+ .unwrap();
190
+ wait_for(
191
+ || status_has_state(&daemon, "queued"),
192
+ Duration::from_secs(5),
193
+ )
194
+ .unwrap();
195
+ let queued_status = daemon.status_json();
196
+ assert!(queued_status["jobs"].as_array().unwrap().iter().any(|job| {
197
+ job["queued_reason"]
198
+ .as_str()
199
+ .is_some_and(|reason| reason.contains("soft memory pressure"))
200
+ }));
201
+
202
+ daemon.set_pressure("normal");
203
+ let code = wait_child(&mut queued, Duration::from_secs(5)).unwrap();
204
+ assert_eq!(code, Some(0));
205
+ }
206
+
207
+ #[test]
208
+ fn simulated_critical_pressure_pauses_and_recovery_resumes_guarded_group() {
209
+ let daemon = DaemonGuard::start();
210
+ let mut child = daemon
211
+ .cli()
212
+ .args(["run", "--priority", "low", "--", "/bin/sleep", "3"])
213
+ .spawn()
214
+ .unwrap();
215
+ wait_for(
216
+ || status_has_state(&daemon, "running"),
217
+ Duration::from_secs(5),
218
+ )
219
+ .unwrap();
220
+ daemon.set_pressure("critical");
221
+ wait_for(
222
+ || {
223
+ let _ = daemon.status_json();
224
+ status_has_state(&daemon, "paused_by_guard")
225
+ },
226
+ Duration::from_secs(5),
227
+ )
228
+ .unwrap();
229
+
230
+ daemon.set_pressure("normal");
231
+ wait_for(
232
+ || {
233
+ let _ = daemon.status_json();
234
+ status_has_state(&daemon, "running") || child.try_wait().unwrap().is_some()
235
+ },
236
+ Duration::from_secs(5),
237
+ )
238
+ .unwrap();
239
+ let _ = child.kill();
240
+ let _ = child.wait();
241
+ }
242
+
243
+ #[test]
244
+ fn daemon_restart_reconciles_still_running_job() {
245
+ let mut daemon = DaemonGuard::start();
246
+ let mut child = daemon
247
+ .cli()
248
+ .args(["run", "--", "/bin/sleep", "3"])
249
+ .spawn()
250
+ .unwrap();
251
+ wait_for(
252
+ || status_has_state(&daemon, "running"),
253
+ Duration::from_secs(5),
254
+ )
255
+ .unwrap();
256
+
257
+ daemon.restart();
258
+ wait_for(
259
+ || status_has_event(&daemon, "daemon_restart_reconciled"),
260
+ Duration::from_secs(5),
261
+ )
262
+ .unwrap();
263
+ let status = daemon.status_json();
264
+ assert!(
265
+ status["jobs"]
266
+ .as_array()
267
+ .unwrap()
268
+ .iter()
269
+ .any(|job| { job["status"] == "running" || job["status"] == "exited" })
270
+ );
271
+ let _ = child.kill();
272
+ let _ = child.wait();
273
+ }
274
+
275
+ #[test]
276
+ fn shim_wraps_fake_codex_without_real_codex_and_injects_thread_cap() {
277
+ let daemon = DaemonGuard::start();
278
+ let dir = tempdir().unwrap();
279
+ let real_codex = dir.path().join("codex-real");
280
+ fs::write(&real_codex, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n").unwrap();
281
+ fs::set_permissions(&real_codex, fs::Permissions::from_mode(0o755)).unwrap();
282
+ let shim_dir = dir.path().join("shim");
283
+
284
+ let install = daemon
285
+ .cli()
286
+ .arg("install-shim")
287
+ .arg("codex")
288
+ .arg("--real-path")
289
+ .arg(&real_codex)
290
+ .arg("--shim-dir")
291
+ .arg(&shim_dir)
292
+ .output()
293
+ .unwrap();
294
+ assert_success(&install);
295
+
296
+ let output = Command::new(shim_dir.join("codex"))
297
+ .arg("exec")
298
+ .arg("hello")
299
+ .output()
300
+ .unwrap();
301
+ assert_success(&output);
302
+ let stdout = String::from_utf8_lossy(&output.stdout);
303
+ assert!(stdout.contains("agents.max_threads=1"));
304
+ assert!(stdout.contains("exec"));
305
+
306
+ let output = Command::new(shim_dir.join("codex"))
307
+ .args(["-c", "agents.max_threads=9", "exec"])
308
+ .output()
309
+ .unwrap();
310
+ assert_success(&output);
311
+ let stdout = String::from_utf8_lossy(&output.stdout);
312
+ assert!(stdout.contains("agents.max_threads=9"));
313
+ assert!(!stdout.contains("agents.max_threads=1"));
314
+ }
315
+
316
+ fn bin(name: &str) -> PathBuf {
317
+ let key = format!("CARGO_BIN_EXE_{name}");
318
+ std::env::var_os(&key)
319
+ .map(PathBuf::from)
320
+ .unwrap_or_else(|| {
321
+ let exe = std::env::current_exe().unwrap();
322
+ exe.parent().and_then(Path::parent).unwrap().join(name)
323
+ })
324
+ }
325
+
326
+ fn wait_for(mut predicate: impl FnMut() -> bool, timeout: Duration) -> io::Result<()> {
327
+ let start = Instant::now();
328
+ while start.elapsed() < timeout {
329
+ if predicate() {
330
+ return Ok(());
331
+ }
332
+ thread::sleep(Duration::from_millis(50));
333
+ }
334
+ Err(io::Error::new(
335
+ io::ErrorKind::TimedOut,
336
+ "condition timed out",
337
+ ))
338
+ }
339
+
340
+ fn status_has_state(daemon: &DaemonGuard, state: &str) -> bool {
341
+ let Ok(status) = daemon.status_json_result() else {
342
+ return false;
343
+ };
344
+ status["jobs"]
345
+ .as_array()
346
+ .unwrap()
347
+ .iter()
348
+ .any(|job| job["status"] == state)
349
+ }
350
+
351
+ fn status_has_event(daemon: &DaemonGuard, event_type: &str) -> bool {
352
+ let Ok(status) = daemon.status_json_result() else {
353
+ return false;
354
+ };
355
+ status["events"]
356
+ .as_array()
357
+ .unwrap()
358
+ .iter()
359
+ .any(|event| event["event_type"] == event_type)
360
+ }
361
+
362
+ fn wait_child(child: &mut Child, timeout: Duration) -> io::Result<Option<i32>> {
363
+ let start = Instant::now();
364
+ loop {
365
+ if let Some(status) = child.try_wait()? {
366
+ return Ok(status.code());
367
+ }
368
+ if start.elapsed() >= timeout {
369
+ let _ = child.kill();
370
+ return Ok(None);
371
+ }
372
+ thread::sleep(Duration::from_millis(50));
373
+ }
374
+ }
375
+
376
+ fn assert_success(output: &Output) {
377
+ assert!(
378
+ output.status.success(),
379
+ "status={:?}\nstdout={}\nstderr={}",
380
+ output.status.code(),
381
+ String::from_utf8_lossy(&output.stdout),
382
+ String::from_utf8_lossy(&output.stderr)
383
+ );
384
+ }
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "agentguard-core"
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
+ libc.workspace = true
12
+ rusqlite.workspace = true
13
+ serde.workspace = true
14
+ serde_json.workspace = true
15
+ thiserror.workspace = true
16
+ uuid.workspace = true
17
+
18
+ [dev-dependencies]
19
+ tempfile.workspace = true
@@ -0,0 +1,69 @@
1
+ pub mod codex {
2
+ pub fn apply_thread_cap(args: &[String], cap: u32) -> Vec<String> {
3
+ if has_explicit_thread_cap(args) {
4
+ return args.to_vec();
5
+ }
6
+ let mut adjusted = vec!["-c".to_string(), format!("agents.max_threads={cap}")];
7
+ adjusted.extend_from_slice(args);
8
+ adjusted
9
+ }
10
+
11
+ pub fn has_explicit_thread_cap(args: &[String]) -> bool {
12
+ let mut index = 0;
13
+ while index < args.len() {
14
+ let arg = &args[index];
15
+ if arg == "-c" || arg == "--config" {
16
+ if args
17
+ .get(index + 1)
18
+ .is_some_and(|next| config_sets_threads(next))
19
+ {
20
+ return true;
21
+ }
22
+ index += 2;
23
+ continue;
24
+ }
25
+ if arg.strip_prefix("-c=").is_some_and(config_sets_threads)
26
+ || arg
27
+ .strip_prefix("--config=")
28
+ .is_some_and(config_sets_threads)
29
+ || arg.strip_prefix("-c").is_some_and(config_sets_threads)
30
+ {
31
+ return true;
32
+ }
33
+ index += 1;
34
+ }
35
+ false
36
+ }
37
+
38
+ fn config_sets_threads(value: &str) -> bool {
39
+ let trimmed = value.trim();
40
+ trimmed == "agents.max_threads" || trimmed.starts_with("agents.max_threads=")
41
+ }
42
+
43
+ #[cfg(test)]
44
+ mod tests {
45
+ use super::*;
46
+
47
+ #[test]
48
+ fn codex_adapter_injects_thread_cap_when_absent() {
49
+ let args = vec!["exec".to_string(), "fix tests".to_string()];
50
+ let adjusted = apply_thread_cap(&args, 1);
51
+ assert_eq!(adjusted[0], "-c");
52
+ assert_eq!(adjusted[1], "agents.max_threads=1");
53
+ assert_eq!(adjusted[2], "exec");
54
+ }
55
+
56
+ #[test]
57
+ fn codex_adapter_does_not_override_explicit_config() {
58
+ let args = vec![
59
+ "-c".to_string(),
60
+ "agents.max_threads=4".to_string(),
61
+ "exec".to_string(),
62
+ ];
63
+ assert_eq!(apply_thread_cap(&args, 1), args);
64
+
65
+ let args = vec!["--config=agents.max_threads=3".to_string()];
66
+ assert_eq!(apply_thread_cap(&args, 1), args);
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,14 @@
1
+ pub mod adapter;
2
+ pub mod lifecycle;
3
+ pub mod policy;
4
+ pub mod pressure;
5
+ pub mod process;
6
+ pub mod protocol;
7
+ pub mod scheduler;
8
+ pub mod server;
9
+ pub mod store;
10
+ pub mod types;
11
+
12
+ pub use types::{
13
+ EventRecord, JobRecord, JobState, Policy, PressureState, Priority, StatusSnapshot, ToolPath,
14
+ };
@@ -0,0 +1,41 @@
1
+ use crate::types::JobState;
2
+
3
+ pub fn valid_transition(from: JobState, to: JobState) -> bool {
4
+ use JobState::*;
5
+ match (from, to) {
6
+ (PendingAdmission, Queued | Starting | Cancelled | Failed) => true,
7
+ (Queued, Starting | Cancelled | Lost) => true,
8
+ (Starting, Running | Failed | Lost | Cancelled) => true,
9
+ (Running, PausedByGuard | Exiting | Exited | Failed | Lost | Cancelled) => true,
10
+ (PausedByGuard, Running | Exiting | Lost | Cancelled) => true,
11
+ (Exiting, Exited | Failed | Lost | Cancelled) => true,
12
+ (Exited | Failed | Lost | Cancelled, _) => false,
13
+ (same_from, same_to) if same_from == same_to => true,
14
+ _ => false,
15
+ }
16
+ }
17
+
18
+ pub fn ensure_transition(from: JobState, to: JobState) -> anyhow::Result<()> {
19
+ anyhow::ensure!(
20
+ valid_transition(from, to),
21
+ "invalid job lifecycle transition from {from} to {to}"
22
+ );
23
+ Ok(())
24
+ }
25
+
26
+ #[cfg(test)]
27
+ mod tests {
28
+ use super::*;
29
+
30
+ #[test]
31
+ fn validates_job_lifecycle_transitions() {
32
+ assert!(valid_transition(
33
+ JobState::PendingAdmission,
34
+ JobState::Queued
35
+ ));
36
+ assert!(valid_transition(JobState::Queued, JobState::Starting));
37
+ assert!(valid_transition(JobState::Running, JobState::PausedByGuard));
38
+ assert!(!valid_transition(JobState::Exited, JobState::Running));
39
+ assert!(!valid_transition(JobState::Queued, JobState::Exited));
40
+ }
41
+ }
@@ -0,0 +1,117 @@
1
+ use crate::types::Policy;
2
+
3
+ impl Default for Policy {
4
+ fn default() -> Self {
5
+ Self::for_physical_memory(8 * 1024 * 1024 * 1024)
6
+ }
7
+ }
8
+
9
+ impl Policy {
10
+ pub fn for_physical_memory(bytes: u64) -> Self {
11
+ let gib = bytes / 1024 / 1024 / 1024;
12
+ if gib <= 8 {
13
+ Self {
14
+ max_active_jobs: 1,
15
+ soft_memory_pressure_percent: 72,
16
+ critical_memory_pressure_percent: 88,
17
+ recovery_memory_pressure_percent: 62,
18
+ default_codex_max_threads: 1,
19
+ pause_enabled: true,
20
+ queue_timeout_seconds: 1800,
21
+ recovery_window_seconds: 60,
22
+ }
23
+ } else if gib <= 16 {
24
+ Self {
25
+ max_active_jobs: 2,
26
+ soft_memory_pressure_percent: 80,
27
+ critical_memory_pressure_percent: 92,
28
+ recovery_memory_pressure_percent: 70,
29
+ default_codex_max_threads: 2,
30
+ pause_enabled: true,
31
+ queue_timeout_seconds: 1800,
32
+ recovery_window_seconds: 30,
33
+ }
34
+ } else {
35
+ Self {
36
+ max_active_jobs: 3,
37
+ soft_memory_pressure_percent: 85,
38
+ critical_memory_pressure_percent: 94,
39
+ recovery_memory_pressure_percent: 75,
40
+ default_codex_max_threads: 3,
41
+ pause_enabled: true,
42
+ queue_timeout_seconds: 1800,
43
+ recovery_window_seconds: 30,
44
+ }
45
+ }
46
+ }
47
+
48
+ pub fn set_key(&mut self, key: &str, value: &str) -> anyhow::Result<()> {
49
+ match key {
50
+ "max_active_jobs" => self.max_active_jobs = value.parse()?,
51
+ "soft_memory_pressure_percent" => self.soft_memory_pressure_percent = value.parse()?,
52
+ "critical_memory_pressure_percent" => {
53
+ self.critical_memory_pressure_percent = value.parse()?;
54
+ }
55
+ "recovery_memory_pressure_percent" => {
56
+ self.recovery_memory_pressure_percent = value.parse()?;
57
+ }
58
+ "default_codex_max_threads" => self.default_codex_max_threads = value.parse()?,
59
+ "pause_enabled" => self.pause_enabled = parse_bool(value)?,
60
+ "queue_timeout_seconds" => self.queue_timeout_seconds = value.parse()?,
61
+ "recovery_window_seconds" => self.recovery_window_seconds = value.parse()?,
62
+ other => anyhow::bail!("unknown policy key {other:?}"),
63
+ }
64
+ self.validate()
65
+ }
66
+
67
+ pub fn validate(&self) -> anyhow::Result<()> {
68
+ anyhow::ensure!(self.max_active_jobs > 0, "max_active_jobs must be > 0");
69
+ anyhow::ensure!(
70
+ self.recovery_memory_pressure_percent < self.soft_memory_pressure_percent,
71
+ "recovery threshold must be below soft threshold"
72
+ );
73
+ anyhow::ensure!(
74
+ self.soft_memory_pressure_percent < self.critical_memory_pressure_percent,
75
+ "soft threshold must be below critical threshold"
76
+ );
77
+ anyhow::ensure!(
78
+ self.critical_memory_pressure_percent <= 100,
79
+ "critical threshold must be <= 100"
80
+ );
81
+ anyhow::ensure!(
82
+ self.default_codex_max_threads > 0,
83
+ "default_codex_max_threads must be > 0"
84
+ );
85
+ Ok(())
86
+ }
87
+ }
88
+
89
+ fn parse_bool(value: &str) -> anyhow::Result<bool> {
90
+ match value {
91
+ "true" | "1" | "yes" | "on" => Ok(true),
92
+ "false" | "0" | "no" | "off" => Ok(false),
93
+ _ => anyhow::bail!("expected boolean value, got {value:?}"),
94
+ }
95
+ }
96
+
97
+ #[cfg(test)]
98
+ mod tests {
99
+ use super::*;
100
+
101
+ #[test]
102
+ fn selects_8gb_defaults_for_small_machines() {
103
+ let policy = Policy::for_physical_memory(8 * 1024 * 1024 * 1024);
104
+ assert_eq!(policy.max_active_jobs, 1);
105
+ assert_eq!(policy.default_codex_max_threads, 1);
106
+ assert!(policy.pause_enabled);
107
+ }
108
+
109
+ #[test]
110
+ fn updates_mvp_policy_keys() {
111
+ let mut policy = Policy::default();
112
+ policy.set_key("max_active_jobs", "2").unwrap();
113
+ policy.set_key("pause_enabled", "false").unwrap();
114
+ assert_eq!(policy.max_active_jobs, 2);
115
+ assert!(!policy.pause_enabled);
116
+ }
117
+ }