perspt_agent/tools/families/
system.rs1use std::sync::Arc;
10
11use anyhow::Result;
12use perspt_sdk::{AccessMode, EffectKind, FootprintSpec, Resource, ResourceSelector, ToolEntry};
13
14use super::{family_entry, object_schema};
15use crate::candidate::CandidateWorkspace;
16use crate::toolloop::EffectOutcome;
17use crate::tools::handlers::{CandidateHandlerRegistry, CandidateToolHandler};
18
19fn probe_footprint(key: &str) -> FootprintSpec {
20 FootprintSpec::new(vec![ResourceSelector::Literal {
21 resource: Resource::Scoped {
22 family: "system".into(),
23 key: key.into(),
24 },
25 access: AccessMode::Read,
26 }])
27}
28
29pub fn entries() -> Vec<ToolEntry> {
30 vec![
31 family_entry(
32 "sys_info",
33 "Read-only host facts: OS, architecture, and toolchain versions \
34 (rustc, cargo, python, uv, node, git)",
35 EffectKind::SystemProbe,
36 object_schema(&[]),
37 probe_footprint("info"),
38 ),
39 family_entry(
40 "sys_processes",
41 "List running processes using host-native summary fields; command \
42 arguments are never returned",
43 EffectKind::SystemProbe,
44 object_schema(&[]),
45 probe_footprint("processes"),
46 ),
47 family_entry(
48 "sys_disk",
49 "Filesystem usage for a workspace-relative path",
50 EffectKind::SystemProbe,
51 object_schema(&[(
52 "path",
53 "string",
54 "Workspace-relative path (default workspace root)",
55 false,
56 )]),
57 probe_footprint("disk"),
58 ),
59 family_entry(
60 "sys_env",
61 "Environment variable NAMES only; values are withheld so secrets \
62 never enter the conversation",
63 EffectKind::SystemProbe,
64 object_schema(&[]),
65 probe_footprint("env"),
66 ),
67 ]
68}
69
70pub fn register(registry: &mut CandidateHandlerRegistry) -> Result<()> {
71 registry.register("sys_info", Arc::new(SysInfo))?;
72 registry.register("sys_processes", Arc::new(SysProcesses))?;
73 registry.register("sys_disk", Arc::new(SysDisk))?;
74 registry.register("sys_env", Arc::new(SysEnv))?;
75 Ok(())
76}
77
78const OUTPUT_CAP: usize = 16 * 1024;
79
80fn capped(mut output: String) -> String {
81 if output.len() > OUTPUT_CAP {
82 output.truncate(OUTPUT_CAP);
83 output.push_str("\n… (truncated)");
84 }
85 output
86}
87
88fn tool_version(program: &str) -> String {
89 std::process::Command::new(program)
90 .arg("--version")
91 .output()
92 .ok()
93 .filter(|out| out.status.success())
94 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
95 .unwrap_or_else(|| "not found".into())
96}
97
98struct SysInfo;
99
100#[async_trait::async_trait]
101impl CandidateToolHandler for SysInfo {
102 async fn apply(
103 &self,
104 _workspace: &CandidateWorkspace,
105 _call: &perspt_sdk::ProviderToolCall,
106 _entry: &ToolEntry,
107 ) -> Result<EffectOutcome> {
108 let mut lines = vec![
109 format!("os: {}", std::env::consts::OS),
110 format!("arch: {}", std::env::consts::ARCH),
111 ];
112 for program in ["rustc", "cargo", python_program(), "uv", "node", "git"] {
113 lines.push(format!("{program}: {}", tool_version(program)));
114 }
115 Ok(EffectOutcome {
116 output: capped(lines.join("\n")),
117 mutated: false,
118 completed: true,
119 })
120 }
121}
122
123struct SysProcesses;
124
125#[async_trait::async_trait]
126impl CandidateToolHandler for SysProcesses {
127 async fn apply(
128 &self,
129 _workspace: &CandidateWorkspace,
130 _call: &perspt_sdk::ProviderToolCall,
131 _entry: &ToolEntry,
132 ) -> Result<EffectOutcome> {
133 let output = process_summary()?;
134 Ok(EffectOutcome {
135 output: capped(output),
136 mutated: false,
137 completed: true,
138 })
139 }
140}
141
142struct SysDisk;
143
144#[async_trait::async_trait]
145impl CandidateToolHandler for SysDisk {
146 async fn apply(
147 &self,
148 workspace: &CandidateWorkspace,
149 call: &perspt_sdk::ProviderToolCall,
150 _entry: &ToolEntry,
151 ) -> Result<EffectOutcome> {
152 let target = match call.arguments.get("path").and_then(|v| v.as_str()) {
153 Some(relative) => {
154 let relative = workspace.validate_relative(relative)?;
155 workspace.overlay_root().join(relative)
156 }
157 None => workspace.overlay_root().to_path_buf(),
158 };
159 let (total, available) = disk_usage(&target)?;
160 Ok(EffectOutcome {
161 output: format!(
162 "path: {}\ntotal_bytes: {total}\navailable_bytes: {available}",
163 target.display()
164 ),
165 mutated: false,
166 completed: true,
167 })
168 }
169}
170
171#[cfg(windows)]
172fn python_program() -> &'static str {
173 "python"
174}
175
176#[cfg(not(windows))]
177fn python_program() -> &'static str {
178 "python3"
179}
180
181#[cfg(windows)]
182fn process_summary() -> Result<String> {
183 let output = std::process::Command::new("tasklist")
186 .args(["/FO", "CSV", "/NH"])
187 .output()?;
188 anyhow::ensure!(output.status.success(), "tasklist failed");
189 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
190}
191
192#[cfg(not(windows))]
193fn process_summary() -> Result<String> {
194 let output = std::process::Command::new("ps")
197 .args(["-axo", "pid=,pcpu=,pmem=,comm="])
198 .output()?;
199 anyhow::ensure!(output.status.success(), "ps failed");
200 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
201}
202
203#[cfg(not(windows))]
204fn disk_usage(target: &std::path::Path) -> Result<(u64, u64)> {
205 let stat = rustix::fs::statvfs(target)?;
206 let block = stat.f_frsize.max(1);
207 Ok((stat.f_blocks * block, stat.f_bavail * block))
208}
209
210#[cfg(windows)]
211fn disk_usage(target: &std::path::Path) -> Result<(u64, u64)> {
212 let target = target.canonicalize()?;
213 let script = "$d=[System.IO.DriveInfo]::new($args[0]); \
214 Write-Output $d.TotalSize; Write-Output $d.AvailableFreeSpace";
215 let output = std::process::Command::new("powershell.exe")
216 .args([
217 "-NoLogo",
218 "-NoProfile",
219 "-NonInteractive",
220 "-Command",
221 script,
222 ])
223 .arg(target)
224 .output()?;
225 anyhow::ensure!(output.status.success(), "PowerShell disk probe failed");
226 let values = String::from_utf8_lossy(&output.stdout)
227 .lines()
228 .filter_map(|line| line.trim().parse::<u64>().ok())
229 .collect::<Vec<_>>();
230 anyhow::ensure!(
231 values.len() >= 2,
232 "PowerShell disk probe returned invalid output"
233 );
234 Ok((values[0], values[1]))
235}
236
237struct SysEnv;
238
239#[async_trait::async_trait]
240impl CandidateToolHandler for SysEnv {
241 async fn apply(
242 &self,
243 _workspace: &CandidateWorkspace,
244 _call: &perspt_sdk::ProviderToolCall,
245 _entry: &ToolEntry,
246 ) -> Result<EffectOutcome> {
247 let mut names: Vec<String> = std::env::vars_os()
248 .map(|(name, _)| name.to_string_lossy().into_owned())
249 .collect();
250 names.sort();
251 Ok(EffectOutcome {
252 output: capped(names.join("\n")),
253 mutated: false,
254 completed: true,
255 })
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[tokio::test]
264 async fn sys_env_reports_names_without_values() {
265 std::env::set_var("PERSPT_TEST_SECRET", "super-secret-value");
266 let dir = tempfile::tempdir().unwrap();
267 let workspace = CandidateWorkspace::create(dir.path(), "n1", 0, "r1").unwrap();
268 let call = perspt_sdk::ProviderToolCall {
269 call_id: "c1".into(),
270 name: "sys_env".into(),
271 arguments: serde_json::json!({}),
272 };
273 let outcome = SysEnv
274 .apply(&workspace, &call, &entries()[3])
275 .await
276 .unwrap();
277 assert!(outcome.output.contains("PERSPT_TEST_SECRET"));
278 assert!(!outcome.output.contains("super-secret-value"));
279 std::env::remove_var("PERSPT_TEST_SECRET");
280 }
281}