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.
- package/Cargo.lock +727 -0
- package/Cargo.toml +22 -0
- package/README.md +140 -0
- package/crates/agentguard/Cargo.toml +23 -0
- package/crates/agentguard/src/bin/agentguard.rs +460 -0
- package/crates/agentguard/src/bin/agentguardd.rs +23 -0
- package/crates/agentguard/tests/integration.rs +384 -0
- package/crates/agentguard-core/Cargo.toml +19 -0
- package/crates/agentguard-core/src/adapter.rs +69 -0
- package/crates/agentguard-core/src/lib.rs +14 -0
- package/crates/agentguard-core/src/lifecycle.rs +41 -0
- package/crates/agentguard-core/src/policy.rs +117 -0
- package/crates/agentguard-core/src/pressure.rs +186 -0
- package/crates/agentguard-core/src/process.rs +100 -0
- package/crates/agentguard-core/src/protocol.rs +186 -0
- package/crates/agentguard-core/src/scheduler.rs +132 -0
- package/crates/agentguard-core/src/server.rs +613 -0
- package/crates/agentguard-core/src/store.rs +434 -0
- package/crates/agentguard-core/src/types.rs +248 -0
- package/docs/DECISIONS.md +37 -0
- package/docs/TESTING.md +75 -0
- package/npm/bin/agentguard +9 -0
- package/npm/bin/agentguardd +9 -0
- package/npm/bin/darwin-arm64/agentguard +0 -0
- package/npm/bin/darwin-arm64/agentguardd +0 -0
- package/package.json +44 -0
package/Cargo.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
members = ["crates/agentguard-core", "crates/agentguard"]
|
|
3
|
+
resolver = "3"
|
|
4
|
+
|
|
5
|
+
[workspace.package]
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
edition = "2024"
|
|
8
|
+
license = "MIT OR Apache-2.0"
|
|
9
|
+
authors = ["AgentGuard contributors"]
|
|
10
|
+
|
|
11
|
+
[workspace.dependencies]
|
|
12
|
+
agentguard-core = { path = "crates/agentguard-core" }
|
|
13
|
+
anyhow = "1.0.100"
|
|
14
|
+
chrono = { version = "0.4.42", features = ["serde"] }
|
|
15
|
+
clap = { version = "4.5.53", features = ["derive", "env"] }
|
|
16
|
+
libc = "0.2.178"
|
|
17
|
+
rusqlite = { version = "0.37.0", features = ["bundled", "chrono", "serde_json"] }
|
|
18
|
+
serde = { version = "1.0.228", features = ["derive"] }
|
|
19
|
+
serde_json = "1.0.145"
|
|
20
|
+
tempfile = "3.23.0"
|
|
21
|
+
thiserror = "2.0.17"
|
|
22
|
+
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# AgentGuard
|
|
2
|
+
|
|
3
|
+
AgentGuard is a macOS-first local admission controller for AI coding CLI processes. It starts guarded jobs only when local policy and memory pressure allow it, queues new guarded work when the machine is under stress, pauses guarded process groups on critical pressure, and explains those decisions through a small CLI.
|
|
4
|
+
|
|
5
|
+
AgentGuard is not an agent orchestrator, worktree manager, sandbox, cloud IDE, RAM cleaner, or process killer for arbitrary apps. It only manages jobs launched through `agentguard run` or an installed AgentGuard shim.
|
|
6
|
+
|
|
7
|
+
## What ships in this MVP
|
|
8
|
+
|
|
9
|
+
- `agentguardd`: launchd-friendly user daemon with a user-only Unix domain socket.
|
|
10
|
+
- `agentguard`: CLI for `status`, `run`, `policy`, `pause`, `resume`, `cancel`, `doctor`, and `install-shim`.
|
|
11
|
+
- SQLite state for jobs, process samples, events, policy, and tool path mappings.
|
|
12
|
+
- Pure scheduler and policy modules with deterministic tests.
|
|
13
|
+
- Process-group spawning with `SIGSTOP` and `SIGCONT` for guarded jobs only.
|
|
14
|
+
- Codex adapter that injects `-c agents.max_threads=N` unless the user already supplied an explicit `agents.max_threads` config.
|
|
15
|
+
- Deterministic fake pressure support for tests through `AGENTGUARD_FAKE_PRESSURE_FILE`.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
The npm package currently ships prebuilt binaries for macOS on Apple Silicon:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install -g agentguard-local
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The npm package is named `agentguard-local` to avoid collisions with existing packages. It installs the `agentguard` and `agentguardd` commands.
|
|
26
|
+
|
|
27
|
+
Then start the daemon:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
agentguardd \
|
|
31
|
+
--socket "$HOME/.agentguard/agentguard.sock" \
|
|
32
|
+
--db "$HOME/.agentguard/state.db" \
|
|
33
|
+
--foreground
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Build From Source
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
cargo build --workspace --all-features
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If Cargo is not on `PATH`, install Rust first. On macOS with Homebrew:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
brew install rust
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Run The Daemon
|
|
49
|
+
|
|
50
|
+
Foreground mode is easiest while testing from a source checkout:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
target/debug/agentguardd \
|
|
54
|
+
--socket "$HOME/.agentguard/agentguard.sock" \
|
|
55
|
+
--db "$HOME/.agentguard/state.db" \
|
|
56
|
+
--foreground
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The daemon listens only on a local Unix domain socket and stores state in SQLite. It does not open a TCP listener.
|
|
60
|
+
|
|
61
|
+
## Use The CLI
|
|
62
|
+
|
|
63
|
+
Check status:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
target/debug/agentguard status
|
|
67
|
+
target/debug/agentguard status --json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Run a guarded command:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
target/debug/agentguard run -- /bin/echo hello
|
|
74
|
+
target/debug/agentguard run --priority low -- /bin/sleep 30
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Manage policy:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
target/debug/agentguard policy get
|
|
81
|
+
target/debug/agentguard policy set max_active_jobs 1
|
|
82
|
+
target/debug/agentguard policy set pause_enabled true
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Pause, resume, or cancel a guarded job:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
target/debug/agentguard pause <job-id>
|
|
89
|
+
target/debug/agentguard resume <job-id>
|
|
90
|
+
target/debug/agentguard cancel <job-id>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Run diagnostics:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
target/debug/agentguard doctor
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Install A Temporary Shim
|
|
100
|
+
|
|
101
|
+
Install a shim for a real tool path into a directory you control:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
target/debug/agentguard install-shim codex \
|
|
105
|
+
--real-path /path/to/real/codex \
|
|
106
|
+
--shim-dir "$HOME/.agentguard/bin"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Then put the shim directory before the real tool in `PATH`:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
export PATH="$HOME/.agentguard/bin:$PATH"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The shim dispatches through `agentguard run`, preserves cwd/env/stdin/stdout/stderr/args/exit code, and avoids PATH recursion by storing the real binary path.
|
|
116
|
+
|
|
117
|
+
## Uninstall
|
|
118
|
+
|
|
119
|
+
Stop any foreground `agentguardd` process, remove the shim directory from `PATH`, and delete the local data directory if you no longer want state:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
rm -rf "$HOME/.agentguard"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If you later add a launchd plist, unload and remove it before deleting state.
|
|
126
|
+
|
|
127
|
+
## Safety Model
|
|
128
|
+
|
|
129
|
+
- AgentGuard never pauses, resumes, cancels, or kills unguarded user processes.
|
|
130
|
+
- Automatic pause/resume uses process-group signals only for jobs that AgentGuard admitted and registered.
|
|
131
|
+
- Cancel sends `SIGTERM` only to the guarded process group for the selected job.
|
|
132
|
+
- Job commands and events are stored locally in SQLite. Prompts are not separately captured.
|
|
133
|
+
- The v1 control plane is a user-only Unix socket with `0600` permissions.
|
|
134
|
+
|
|
135
|
+
## Limitations
|
|
136
|
+
|
|
137
|
+
- v1 does not queue individual Codex subagents after Codex is already running. It caps fan-out at launch.
|
|
138
|
+
- The current system pressure sampler uses stable macOS command output as a documented fallback while preserving the sampler boundary for native API work.
|
|
139
|
+
- There is no menu bar UI, cloud offload, container runtime, microVM dependency, or team dashboard.
|
|
140
|
+
- Pause does not immediately free all memory; it stops execution so pressure can recover.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "agentguard"
|
|
3
|
+
version.workspace = true
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
license.workspace = true
|
|
6
|
+
authors.workspace = true
|
|
7
|
+
|
|
8
|
+
[dependencies]
|
|
9
|
+
agentguard-core.workspace = true
|
|
10
|
+
anyhow.workspace = true
|
|
11
|
+
clap.workspace = true
|
|
12
|
+
serde_json.workspace = true
|
|
13
|
+
|
|
14
|
+
[[bin]]
|
|
15
|
+
name = "agentguard"
|
|
16
|
+
path = "src/bin/agentguard.rs"
|
|
17
|
+
|
|
18
|
+
[[bin]]
|
|
19
|
+
name = "agentguardd"
|
|
20
|
+
path = "src/bin/agentguardd.rs"
|
|
21
|
+
|
|
22
|
+
[dev-dependencies]
|
|
23
|
+
tempfile.workspace = true
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
use agentguard_core::adapter::codex;
|
|
2
|
+
use agentguard_core::process::{exit_code, spawn_in_process_group};
|
|
3
|
+
use agentguard_core::protocol::{DoctorReport, PROTOCOL_VERSION, Request, Response};
|
|
4
|
+
use agentguard_core::server::{default_db_path, default_socket_path};
|
|
5
|
+
use agentguard_core::store::Store;
|
|
6
|
+
use agentguard_core::types::{Priority, StatusSnapshot};
|
|
7
|
+
use clap::{Parser, Subcommand};
|
|
8
|
+
use std::io::{BufRead, BufReader, Write};
|
|
9
|
+
use std::os::unix::net::UnixStream;
|
|
10
|
+
use std::path::{Path, PathBuf};
|
|
11
|
+
use std::time::Duration;
|
|
12
|
+
|
|
13
|
+
#[derive(Debug, Parser)]
|
|
14
|
+
#[command(
|
|
15
|
+
name = "agentguard",
|
|
16
|
+
about = "RAM-aware admission control for local AI coding agents"
|
|
17
|
+
)]
|
|
18
|
+
struct Cli {
|
|
19
|
+
#[arg(long, env = "AGENTGUARD_SOCKET", global = true)]
|
|
20
|
+
socket: Option<PathBuf>,
|
|
21
|
+
#[arg(long, env = "AGENTGUARD_DB", global = true)]
|
|
22
|
+
db: Option<PathBuf>,
|
|
23
|
+
#[command(subcommand)]
|
|
24
|
+
command: Commands,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Debug, Subcommand)]
|
|
28
|
+
enum Commands {
|
|
29
|
+
Status {
|
|
30
|
+
#[arg(long)]
|
|
31
|
+
json: bool,
|
|
32
|
+
},
|
|
33
|
+
Run {
|
|
34
|
+
#[arg(long, default_value = "normal")]
|
|
35
|
+
priority: String,
|
|
36
|
+
#[arg(long, hide = true)]
|
|
37
|
+
adapter: Option<String>,
|
|
38
|
+
#[arg(long, hide = true)]
|
|
39
|
+
real_path: Option<PathBuf>,
|
|
40
|
+
#[arg(trailing_var_arg = true, required = true, num_args = 1..)]
|
|
41
|
+
command: Vec<String>,
|
|
42
|
+
},
|
|
43
|
+
Policy {
|
|
44
|
+
#[command(subcommand)]
|
|
45
|
+
command: PolicyCommand,
|
|
46
|
+
},
|
|
47
|
+
Pause {
|
|
48
|
+
job_id: String,
|
|
49
|
+
},
|
|
50
|
+
Resume {
|
|
51
|
+
job_id: String,
|
|
52
|
+
},
|
|
53
|
+
Cancel {
|
|
54
|
+
job_id: String,
|
|
55
|
+
},
|
|
56
|
+
Doctor,
|
|
57
|
+
InstallShim {
|
|
58
|
+
tool_name: String,
|
|
59
|
+
#[arg(long)]
|
|
60
|
+
real_path: PathBuf,
|
|
61
|
+
#[arg(long)]
|
|
62
|
+
shim_dir: PathBuf,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[derive(Debug, Subcommand)]
|
|
67
|
+
enum PolicyCommand {
|
|
68
|
+
Get,
|
|
69
|
+
Set { key: String, value: String },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn main() -> anyhow::Result<()> {
|
|
73
|
+
let cli = Cli::parse();
|
|
74
|
+
let socket = cli.socket.unwrap_or_else(default_socket_path);
|
|
75
|
+
let db = cli.db.unwrap_or_else(default_db_path);
|
|
76
|
+
match cli.command {
|
|
77
|
+
Commands::Status { json } => status(&socket, json),
|
|
78
|
+
Commands::Run {
|
|
79
|
+
priority,
|
|
80
|
+
adapter,
|
|
81
|
+
real_path,
|
|
82
|
+
command,
|
|
83
|
+
} => run_command(&socket, priority, adapter, real_path, command),
|
|
84
|
+
Commands::Policy { command } => policy(&socket, command),
|
|
85
|
+
Commands::Pause { job_id } => ack(
|
|
86
|
+
&socket,
|
|
87
|
+
Request::Pause {
|
|
88
|
+
protocol_version: PROTOCOL_VERSION,
|
|
89
|
+
job_id,
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
Commands::Resume { job_id } => ack(
|
|
93
|
+
&socket,
|
|
94
|
+
Request::Resume {
|
|
95
|
+
protocol_version: PROTOCOL_VERSION,
|
|
96
|
+
job_id,
|
|
97
|
+
},
|
|
98
|
+
),
|
|
99
|
+
Commands::Cancel { job_id } => ack(
|
|
100
|
+
&socket,
|
|
101
|
+
Request::Cancel {
|
|
102
|
+
protocol_version: PROTOCOL_VERSION,
|
|
103
|
+
job_id,
|
|
104
|
+
},
|
|
105
|
+
),
|
|
106
|
+
Commands::Doctor => doctor(&socket, &db),
|
|
107
|
+
Commands::InstallShim {
|
|
108
|
+
tool_name,
|
|
109
|
+
real_path,
|
|
110
|
+
shim_dir,
|
|
111
|
+
} => install_shim(&socket, &db, &tool_name, &real_path, &shim_dir),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn status(socket: &Path, json: bool) -> anyhow::Result<()> {
|
|
116
|
+
match send(
|
|
117
|
+
socket,
|
|
118
|
+
Request::Status {
|
|
119
|
+
protocol_version: PROTOCOL_VERSION,
|
|
120
|
+
include_processes: true,
|
|
121
|
+
},
|
|
122
|
+
)? {
|
|
123
|
+
Response::Status { snapshot, .. } => {
|
|
124
|
+
if json {
|
|
125
|
+
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
|
126
|
+
} else {
|
|
127
|
+
print_status(&snapshot);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
response => handle_unexpected(response)?,
|
|
131
|
+
}
|
|
132
|
+
Ok(())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fn run_command(
|
|
136
|
+
socket: &Path,
|
|
137
|
+
priority: String,
|
|
138
|
+
adapter: Option<String>,
|
|
139
|
+
real_path: Option<PathBuf>,
|
|
140
|
+
command: Vec<String>,
|
|
141
|
+
) -> anyhow::Result<()> {
|
|
142
|
+
let priority: Priority = priority.parse().map_err(anyhow::Error::msg)?;
|
|
143
|
+
let display_command = command
|
|
144
|
+
.first()
|
|
145
|
+
.ok_or_else(|| anyhow::anyhow!("missing command after --"))?
|
|
146
|
+
.clone();
|
|
147
|
+
let display_args = command.iter().skip(1).cloned().collect::<Vec<_>>();
|
|
148
|
+
let adapter = adapter.unwrap_or_else(|| {
|
|
149
|
+
Path::new(&display_command)
|
|
150
|
+
.file_name()
|
|
151
|
+
.and_then(|name| name.to_str())
|
|
152
|
+
.unwrap_or(&display_command)
|
|
153
|
+
.to_string()
|
|
154
|
+
});
|
|
155
|
+
let cwd = std::env::current_dir()?.display().to_string();
|
|
156
|
+
|
|
157
|
+
let admission = send(
|
|
158
|
+
socket,
|
|
159
|
+
Request::Admission {
|
|
160
|
+
protocol_version: PROTOCOL_VERSION,
|
|
161
|
+
adapter: adapter.clone(),
|
|
162
|
+
command: display_command.clone(),
|
|
163
|
+
args: display_args.clone(),
|
|
164
|
+
cwd,
|
|
165
|
+
priority,
|
|
166
|
+
tty: true,
|
|
167
|
+
},
|
|
168
|
+
)?;
|
|
169
|
+
|
|
170
|
+
let (job_id, mut codex_max_threads) = match admission {
|
|
171
|
+
Response::Admission {
|
|
172
|
+
job_id,
|
|
173
|
+
admitted,
|
|
174
|
+
reason,
|
|
175
|
+
codex_max_threads,
|
|
176
|
+
..
|
|
177
|
+
} => {
|
|
178
|
+
if !admitted {
|
|
179
|
+
eprintln!(
|
|
180
|
+
"agentguard: queued {job_id}: {}",
|
|
181
|
+
reason.as_deref().unwrap_or("waiting for admission")
|
|
182
|
+
);
|
|
183
|
+
wait_for_lease(socket, &job_id)?;
|
|
184
|
+
}
|
|
185
|
+
(job_id, codex_max_threads)
|
|
186
|
+
}
|
|
187
|
+
response => handle_unexpected(response)?,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if codex_max_threads == 0 {
|
|
191
|
+
codex_max_threads = 1;
|
|
192
|
+
}
|
|
193
|
+
let spawn_command = real_path
|
|
194
|
+
.as_ref()
|
|
195
|
+
.map(|path| path.display().to_string())
|
|
196
|
+
.unwrap_or(display_command);
|
|
197
|
+
let mut spawn_args = display_args;
|
|
198
|
+
if adapter == "codex" {
|
|
199
|
+
spawn_args = codex::apply_thread_cap(&spawn_args, codex_max_threads);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let mut child = spawn_in_process_group(&spawn_command, &spawn_args)?;
|
|
203
|
+
let root_pid = child.id() as i64;
|
|
204
|
+
let pgid = root_pid;
|
|
205
|
+
let _ = send(
|
|
206
|
+
socket,
|
|
207
|
+
Request::Register {
|
|
208
|
+
protocol_version: PROTOCOL_VERSION,
|
|
209
|
+
job_id: job_id.clone(),
|
|
210
|
+
root_pid,
|
|
211
|
+
pgid,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
let status = child.wait()?;
|
|
215
|
+
let code = exit_code(status);
|
|
216
|
+
let _ = send(
|
|
217
|
+
socket,
|
|
218
|
+
Request::Exit {
|
|
219
|
+
protocol_version: PROTOCOL_VERSION,
|
|
220
|
+
job_id,
|
|
221
|
+
exit_code: code,
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
std::process::exit(code);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fn wait_for_lease(socket: &Path, job_id: &str) -> anyhow::Result<()> {
|
|
228
|
+
let mut last_reason = String::new();
|
|
229
|
+
loop {
|
|
230
|
+
std::thread::sleep(Duration::from_millis(250));
|
|
231
|
+
match send(
|
|
232
|
+
socket,
|
|
233
|
+
Request::LeaseCheck {
|
|
234
|
+
protocol_version: PROTOCOL_VERSION,
|
|
235
|
+
job_id: job_id.to_string(),
|
|
236
|
+
},
|
|
237
|
+
)? {
|
|
238
|
+
Response::Lease {
|
|
239
|
+
admitted,
|
|
240
|
+
state,
|
|
241
|
+
reason,
|
|
242
|
+
..
|
|
243
|
+
} => {
|
|
244
|
+
if admitted {
|
|
245
|
+
return Ok(());
|
|
246
|
+
}
|
|
247
|
+
if matches!(state.as_str(), "cancelled" | "failed" | "lost" | "exited") {
|
|
248
|
+
anyhow::bail!("queued job ended before admission with state {state}");
|
|
249
|
+
}
|
|
250
|
+
let reason = reason.unwrap_or_else(|| "waiting for admission".to_string());
|
|
251
|
+
if reason != last_reason {
|
|
252
|
+
eprintln!("agentguard: still queued {job_id}: {reason}");
|
|
253
|
+
last_reason = reason;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
response => handle_unexpected(response)?,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
fn policy(socket: &Path, command: PolicyCommand) -> anyhow::Result<()> {
|
|
262
|
+
let request = match command {
|
|
263
|
+
PolicyCommand::Get => Request::PolicyGet {
|
|
264
|
+
protocol_version: PROTOCOL_VERSION,
|
|
265
|
+
},
|
|
266
|
+
PolicyCommand::Set { key, value } => Request::PolicySet {
|
|
267
|
+
protocol_version: PROTOCOL_VERSION,
|
|
268
|
+
key,
|
|
269
|
+
value,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
match send(socket, request)? {
|
|
273
|
+
Response::Policy { policy, .. } => {
|
|
274
|
+
println!("{}", serde_json::to_string_pretty(&policy)?);
|
|
275
|
+
}
|
|
276
|
+
response => handle_unexpected(response)?,
|
|
277
|
+
}
|
|
278
|
+
Ok(())
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fn ack(socket: &Path, request: Request) -> anyhow::Result<()> {
|
|
282
|
+
match send(socket, request)? {
|
|
283
|
+
Response::Ack { message, .. } => println!("{message}"),
|
|
284
|
+
response => handle_unexpected(response)?,
|
|
285
|
+
}
|
|
286
|
+
Ok(())
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
fn doctor(socket: &Path, db: &Path) -> anyhow::Result<()> {
|
|
290
|
+
match send(
|
|
291
|
+
socket,
|
|
292
|
+
Request::Doctor {
|
|
293
|
+
protocol_version: PROTOCOL_VERSION,
|
|
294
|
+
},
|
|
295
|
+
) {
|
|
296
|
+
Ok(Response::Doctor { report, .. }) => print_doctor(&report),
|
|
297
|
+
Ok(response) => handle_unexpected(response)?,
|
|
298
|
+
Err(_) => {
|
|
299
|
+
let report = local_doctor(socket, db)?;
|
|
300
|
+
print_doctor(&report);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
Ok(())
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
fn install_shim(
|
|
307
|
+
socket: &Path,
|
|
308
|
+
db: &Path,
|
|
309
|
+
tool_name: &str,
|
|
310
|
+
real_path: &Path,
|
|
311
|
+
shim_dir: &Path,
|
|
312
|
+
) -> anyhow::Result<()> {
|
|
313
|
+
anyhow::ensure!(
|
|
314
|
+
real_path.exists(),
|
|
315
|
+
"real path does not exist: {}",
|
|
316
|
+
real_path.display()
|
|
317
|
+
);
|
|
318
|
+
std::fs::create_dir_all(shim_dir)?;
|
|
319
|
+
let agentguard = std::env::current_exe()?;
|
|
320
|
+
let shim_path = shim_dir.join(tool_name);
|
|
321
|
+
let script = format!(
|
|
322
|
+
"#!/bin/sh\nexec {} --socket {} run --adapter {} --real-path {} -- {} \"$@\"\n",
|
|
323
|
+
shell_quote(&agentguard.display().to_string()),
|
|
324
|
+
shell_quote(&socket.display().to_string()),
|
|
325
|
+
shell_quote(tool_name),
|
|
326
|
+
shell_quote(&real_path.display().to_string()),
|
|
327
|
+
shell_quote(tool_name),
|
|
328
|
+
);
|
|
329
|
+
std::fs::write(&shim_path, script)?;
|
|
330
|
+
let mut perms = std::fs::metadata(&shim_path)?.permissions();
|
|
331
|
+
#[cfg(unix)]
|
|
332
|
+
{
|
|
333
|
+
use std::os::unix::fs::PermissionsExt;
|
|
334
|
+
perms.set_mode(0o755);
|
|
335
|
+
}
|
|
336
|
+
std::fs::set_permissions(&shim_path, perms)?;
|
|
337
|
+
|
|
338
|
+
let request = Request::ToolPathSet {
|
|
339
|
+
protocol_version: PROTOCOL_VERSION,
|
|
340
|
+
tool: tool_name.to_string(),
|
|
341
|
+
real_path: real_path.display().to_string(),
|
|
342
|
+
};
|
|
343
|
+
if send(socket, request).is_err() {
|
|
344
|
+
let store = Store::open(db)?;
|
|
345
|
+
store.set_tool_path(tool_name, &real_path.display().to_string())?;
|
|
346
|
+
}
|
|
347
|
+
println!("{}", shim_path.display());
|
|
348
|
+
Ok(())
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
fn send(socket: &Path, request: Request) -> anyhow::Result<Response> {
|
|
352
|
+
let mut stream = UnixStream::connect(socket).map_err(|err| {
|
|
353
|
+
anyhow::anyhow!(
|
|
354
|
+
"could not connect to daemon at {}: {err}. Start agentguardd first.",
|
|
355
|
+
socket.display()
|
|
356
|
+
)
|
|
357
|
+
})?;
|
|
358
|
+
serde_json::to_writer(&mut stream, &request)?;
|
|
359
|
+
stream.write_all(b"\n")?;
|
|
360
|
+
stream.flush()?;
|
|
361
|
+
|
|
362
|
+
let mut reader = BufReader::new(stream);
|
|
363
|
+
let mut line = String::new();
|
|
364
|
+
reader.read_line(&mut line)?;
|
|
365
|
+
let response: Response = serde_json::from_str(&line)?;
|
|
366
|
+
if let Response::Error { message, .. } = &response {
|
|
367
|
+
anyhow::bail!("{message}");
|
|
368
|
+
}
|
|
369
|
+
Ok(response)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
fn print_status(snapshot: &StatusSnapshot) {
|
|
373
|
+
println!("Pressure: {}", snapshot.pressure);
|
|
374
|
+
println!(
|
|
375
|
+
"Policy: max_active_jobs={}, codex_threads={}, pause_enabled={}",
|
|
376
|
+
snapshot.policy.max_active_jobs,
|
|
377
|
+
snapshot.policy.default_codex_max_threads,
|
|
378
|
+
snapshot.policy.pause_enabled
|
|
379
|
+
);
|
|
380
|
+
println!("Jobs:");
|
|
381
|
+
if snapshot.jobs.is_empty() {
|
|
382
|
+
println!(" none");
|
|
383
|
+
}
|
|
384
|
+
for job in &snapshot.jobs {
|
|
385
|
+
let reason = job
|
|
386
|
+
.queued_reason
|
|
387
|
+
.as_ref()
|
|
388
|
+
.map(|reason| format!(" - {reason}"))
|
|
389
|
+
.unwrap_or_default();
|
|
390
|
+
println!(
|
|
391
|
+
" {} {} {} {}{}",
|
|
392
|
+
job.id, job.status, job.priority, job.command, reason
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
fn print_doctor(report: &DoctorReport) {
|
|
398
|
+
println!("OS supported: {}", report.os_supported);
|
|
399
|
+
println!("OS: {}", report.os);
|
|
400
|
+
println!(
|
|
401
|
+
"Socket: {} ({})",
|
|
402
|
+
report.socket_path,
|
|
403
|
+
if report.socket_exists {
|
|
404
|
+
"present"
|
|
405
|
+
} else {
|
|
406
|
+
"missing"
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
println!(
|
|
410
|
+
"Database: {} ({})",
|
|
411
|
+
report.db_path,
|
|
412
|
+
if report.db_exists {
|
|
413
|
+
"present"
|
|
414
|
+
} else {
|
|
415
|
+
"missing"
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
println!("launchd plist: {}", report.launchd_plist_exists);
|
|
419
|
+
println!("Tool paths:");
|
|
420
|
+
if report.discovered_tools.is_empty() {
|
|
421
|
+
println!(" none");
|
|
422
|
+
}
|
|
423
|
+
for (tool, path) in &report.discovered_tools {
|
|
424
|
+
println!(" {tool}: {path}");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
fn local_doctor(socket: &Path, db: &Path) -> anyhow::Result<DoctorReport> {
|
|
429
|
+
let discovered_tools = if db.exists() {
|
|
430
|
+
Store::open(db)?
|
|
431
|
+
.list_tool_paths()?
|
|
432
|
+
.into_iter()
|
|
433
|
+
.map(|tool| (tool.tool, tool.real_path))
|
|
434
|
+
.collect()
|
|
435
|
+
} else {
|
|
436
|
+
vec![]
|
|
437
|
+
};
|
|
438
|
+
let plist = std::env::var_os("HOME")
|
|
439
|
+
.map(PathBuf::from)
|
|
440
|
+
.unwrap_or_default()
|
|
441
|
+
.join("Library/LaunchAgents/dev.agentguard.agentguardd.plist");
|
|
442
|
+
Ok(DoctorReport {
|
|
443
|
+
os_supported: std::env::consts::OS == "macos",
|
|
444
|
+
os: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
|
|
445
|
+
socket_path: socket.display().to_string(),
|
|
446
|
+
socket_exists: socket.exists(),
|
|
447
|
+
db_path: db.display().to_string(),
|
|
448
|
+
db_exists: db.exists(),
|
|
449
|
+
launchd_plist_exists: plist.exists(),
|
|
450
|
+
discovered_tools,
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
fn handle_unexpected<T>(response: Response) -> anyhow::Result<T> {
|
|
455
|
+
anyhow::bail!("unexpected daemon response: {response:?}")
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fn shell_quote(value: &str) -> String {
|
|
459
|
+
format!("'{}'", value.replace('\'', "'\\''"))
|
|
460
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
use agentguard_core::server::{DaemonConfig, default_db_path, default_socket_path, run_daemon};
|
|
2
|
+
use clap::Parser;
|
|
3
|
+
use std::path::PathBuf;
|
|
4
|
+
|
|
5
|
+
#[derive(Debug, Parser)]
|
|
6
|
+
#[command(name = "agentguardd", about = "AgentGuard user daemon")]
|
|
7
|
+
struct Args {
|
|
8
|
+
#[arg(long, env = "AGENTGUARD_SOCKET")]
|
|
9
|
+
socket: Option<PathBuf>,
|
|
10
|
+
#[arg(long, env = "AGENTGUARD_DB")]
|
|
11
|
+
db: Option<PathBuf>,
|
|
12
|
+
#[arg(long)]
|
|
13
|
+
foreground: bool,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fn main() -> anyhow::Result<()> {
|
|
17
|
+
let args = Args::parse();
|
|
18
|
+
run_daemon(DaemonConfig {
|
|
19
|
+
socket_path: args.socket.unwrap_or_else(default_socket_path),
|
|
20
|
+
db_path: args.db.unwrap_or_else(default_db_path),
|
|
21
|
+
foreground: args.foreground,
|
|
22
|
+
})
|
|
23
|
+
}
|