Skip to main content

perspt_agent/tools/families/
system.rs

1//! System explorer family: read-only operating-system and toolchain facts.
2//!
3//! Every probe is `EffectKind::SystemProbe` (read-only) with a scoped
4//! footprint keyed by the probe name, so probes serialize against
5//! themselves, never against the workspace. `sys_env` returns variable
6//! **names only** — values are withheld so secrets never enter the
7//! conversation.
8
9use 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    // CSV fields are image name, PID, session name, session number, and
184    // memory usage. No command line or environment is exposed.
185    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    // `comm=` prints the executable name only: other processes' arguments
195    // (which may embed secrets) never enter the output.
196    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}