Skip to main content

netget/scripting/
process_io.rs

1//! Resource and lifetime guards for trusted interpreter processes.
2
3use std::io;
4use tokio::io::{AsyncRead, AsyncReadExt};
5
6pub const MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024;
7pub const MAX_STDERR_BYTES: usize = 1024 * 1024;
8
9/// Abort the whole Unix process group on completion, failure or cancellation.
10/// Scripts remain trusted: a child that deliberately creates a new session can
11/// escape this cleanup. Windows uses a kill-on-close Job Object.
12pub struct ProcessGroup {
13    #[cfg(unix)]
14    pid: Option<u32>,
15    #[cfg(windows)]
16    job: std::os::windows::io::OwnedHandle,
17}
18
19/// Environment variables an interpreter child never inherits.
20///
21/// Handler scripts run as the operator and are not sandboxed — `AGENTS.md`'s trust
22/// boundary — but a script's job is to answer a network event, and the one secret this
23/// process holds for its own use is the model backend's API key. Nothing a handler does
24/// needs it, while a handler supplied over MCP, loaded from a `.netget` file or written by
25/// the model in operator chat could `print(os.environ["NETGET_API_KEY"])` into its stderr,
26/// which is logged. The same goes for every other credential-shaped name: the names below
27/// are removed exactly, any variable whose name contains one of [`SECRET_NAME_PARTS`] is
28/// removed, and so is any whose name ends with one of [`SECRET_NAME_SUFFIXES`]. All three
29/// compare case-insensitively. `PATH`, `HOME`, `LANG` and the rest pass through, so
30/// interpreters still find their modules.
31///
32/// This is defence in depth, not a boundary. A script runs as the same user as NetGet, so it
33/// can read `~/.aws/credentials`, `~/.netrc` or the parent's environment through
34/// `/proc/<ppid>/environ` where the platform exposes it; removing names from the child's
35/// environment only keeps a secret out of the place a careless handler would print it from.
36pub const STRIPPED_ENV: &[&str] = &[
37    "NETGET_API_KEY",
38    "OPENAI_API_KEY",
39    "ANTHROPIC_API_KEY",
40    "NETGET_MCP_TOKEN",
41    "AWS_SECRET_ACCESS_KEY",
42    "AWS_SESSION_TOKEN",
43    "GITHUB_TOKEN",
44    "GH_TOKEN",
45    "NPM_TOKEN",
46];
47
48/// Substrings (upper case) of an environment variable name that mark it as a credential.
49pub const SECRET_NAME_PARTS: &[&str] = &[
50    "SECRET",
51    "PASSWORD",
52    "PASSWD",
53    "API_KEY",
54    "APIKEY",
55    "ACCESS_TOKEN",
56    "AUTH_TOKEN",
57    "PRIVATE_KEY",
58    "CREDENTIAL",
59];
60
61/// Suffixes (upper case) of an environment variable name that mark it as a credential:
62/// `HF_TOKEN`, `CI_JOB_TOKEN`, `STRIPE_SECRET`, `MINIO_ACCESS_KEY`. The `_TOKEN` and
63/// `_ACCESS_KEY` cases are the ones no entry in [`SECRET_NAME_PARTS`] covers.
64pub const SECRET_NAME_SUFFIXES: &[&str] =
65    &["_TOKEN", "_SECRET", "_PASSWORD", "_API_KEY", "_ACCESS_KEY"];
66
67/// Whether an environment variable named `name` is withheld from interpreter children.
68pub fn env_is_stripped(name: &str) -> bool {
69    let upper = name.to_ascii_uppercase();
70    STRIPPED_ENV.contains(&upper.as_str())
71        || SECRET_NAME_PARTS.iter().any(|part| upper.contains(part))
72        || SECRET_NAME_SUFFIXES
73            .iter()
74            .any(|suffix| upper.ends_with(suffix))
75}
76
77impl ProcessGroup {
78    /// Put the child in its own process group (so a timeout can kill its descendants) and
79    /// withhold credential-bearing environment variables from it; see [`STRIPPED_ENV`].
80    /// Every interpreter spawn in this module's callers goes through here.
81    pub fn configure(command: &mut tokio::process::Command) {
82        #[cfg(unix)]
83        command.process_group(0);
84        for (name, _) in std::env::vars_os() {
85            if name.to_str().is_some_and(env_is_stripped) {
86                command.env_remove(&name);
87            }
88        }
89    }
90
91    pub fn new(child: &tokio::process::Child) -> io::Result<Self> {
92        #[cfg(windows)]
93        {
94            use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
95            use windows_sys::Win32::System::JobObjects::*;
96            let raw = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
97            if raw.is_null() {
98                return Err(io::Error::last_os_error());
99            }
100            let job = unsafe { OwnedHandle::from_raw_handle(raw) };
101            let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
102            limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
103            let configured = unsafe {
104                SetInformationJobObject(
105                    raw,
106                    JobObjectExtendedLimitInformation,
107                    (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
108                    std::mem::size_of_val(&limits) as u32,
109                )
110            };
111            if configured == 0 {
112                return Err(io::Error::last_os_error());
113            }
114            let process = child
115                .raw_handle()
116                .ok_or_else(|| io::Error::other("script process already exited"))?;
117            if unsafe { AssignProcessToJobObject(job.as_raw_handle(), process) } == 0 {
118                return Err(io::Error::last_os_error());
119            }
120            Ok(Self { job })
121        }
122        #[cfg(not(windows))]
123        {
124            let _ = child;
125            Ok(Self {
126                #[cfg(unix)]
127                pid: child.id(),
128            })
129        }
130    }
131
132    pub fn kill(&self) {
133        #[cfg(windows)]
134        {
135            use std::os::windows::io::AsRawHandle;
136            unsafe {
137                windows_sys::Win32::System::JobObjects::TerminateJobObject(
138                    self.job.as_raw_handle(),
139                    1,
140                );
141            }
142        }
143        #[cfg(unix)]
144        if let Some(pid) = self.pid.and_then(|pid| i32::try_from(pid).ok()) {
145            // The child was spawned into its own process group, never ours.
146            unsafe {
147                libc::kill(-pid, libc::SIGKILL);
148            }
149        }
150    }
151}
152
153impl Drop for ProcessGroup {
154    fn drop(&mut self) {
155        self.kill();
156    }
157}
158
159/// Stop on the first byte beyond the limit, without retaining that excess.
160pub async fn read_bounded<R: AsyncRead + Unpin>(
161    mut reader: R,
162    limit: usize,
163) -> io::Result<Vec<u8>> {
164    let mut output = Vec::new();
165    let mut chunk = vec![0; 8192];
166    loop {
167        let n = reader.read(&mut chunk).await?;
168        if n == 0 {
169            return Ok(output);
170        }
171        if n > limit.saturating_sub(output.len()) {
172            return Err(io::Error::new(
173                io::ErrorKind::InvalidData,
174                format!("script output exceeds the {limit}-byte cap"),
175            ));
176        }
177        output.extend_from_slice(&chunk[..n]);
178    }
179}
180
181pub(crate) async fn read_line_bounded<R: tokio::io::AsyncBufRead + Unpin>(
182    reader: &mut R,
183    limit: usize,
184) -> io::Result<String> {
185    use tokio::io::AsyncBufReadExt;
186    let mut line = Vec::new();
187    loop {
188        let available = reader.fill_buf().await?;
189        if available.is_empty() {
190            return Err(io::Error::new(
191                io::ErrorKind::UnexpectedEof,
192                "resident script exited before a complete response",
193            ));
194        }
195        let newline = available.iter().position(|&byte| byte == b'\n');
196        let take = newline.map_or(available.len(), |n| n + 1);
197        if take > limit.saturating_sub(line.len()) {
198            return Err(io::Error::new(
199                io::ErrorKind::InvalidData,
200                "resident script response exceeds the 8 MiB cap",
201            ));
202        }
203        line.extend_from_slice(&available[..take]);
204        reader.consume(take);
205        if newline.is_some() {
206            return String::from_utf8(line)
207                .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
208        }
209    }
210}
211
212/// A private directory with exclusive creation and cancellation-safe removal.
213pub(crate) struct ScriptDirectory(std::path::PathBuf);
214
215impl ScriptDirectory {
216    pub(crate) fn new() -> io::Result<Self> {
217        let path = std::env::temp_dir().join(format!("netget-script-{}", uuid::Uuid::new_v4()));
218        let mut builder = std::fs::DirBuilder::new();
219        #[cfg(unix)]
220        {
221            use std::os::unix::fs::DirBuilderExt;
222            builder.mode(0o700);
223        }
224        builder.create(&path)?;
225        Ok(Self(path))
226    }
227
228    pub(crate) fn script_path(&self) -> std::path::PathBuf {
229        self.0.join("main.go")
230    }
231}
232
233impl Drop for ScriptDirectory {
234    fn drop(&mut self) {
235        let _ = std::fs::remove_dir_all(&self.0);
236    }
237}