Skip to main content

context_harness/agent_runtime/
developer.rs

1//! Workspace developer tools. Path checks protect ordinary filesystem access;
2//! process execution is an explicit, unsandboxed capability.
3use crate::{
4    agent_resource::Capability,
5    tool_binding::{self, ToolTrustClass},
6    traits::{Tool, ToolContext, ToolRegistry},
7};
8use anyhow::{bail, ensure, Context, Result};
9use async_trait::async_trait;
10use serde::Deserialize;
11use serde_json::{json, Value};
12use std::{
13    path::{Component, Path, PathBuf},
14    process::Stdio,
15    time::Duration,
16};
17use tokio::{io::AsyncReadExt, process::Command};
18
19const FILE_LIMIT: usize = 1024 * 1024;
20const OUTPUT_LIMIT: usize = 128 * 1024;
21const NAMES: [&str; 7] = [
22    "workspace.read",
23    "workspace.search",
24    "git.status",
25    "git.diff",
26    "workspace.create",
27    "workspace.patch",
28    "process.exec",
29];
30
31pub(super) fn capability(name: &str) -> Option<Capability> {
32    match name {
33        "workspace.read" | "workspace.search" | "git.status" | "git.diff" => {
34            Some(Capability::ReadOnly)
35        }
36        "workspace.create" | "workspace.patch" => Some(Capability::WorkspaceWrite),
37        "process.exec" => Some(Capability::ProcessExecute),
38        _ => None,
39    }
40}
41pub(super) fn register(registry: &mut ToolRegistry, root: &Path) -> Result<()> {
42    let root = root.canonicalize()?;
43    for name in NAMES {
44        registry.register(Box::new(DeveloperTool {
45            name,
46            root: root.clone(),
47        }));
48    }
49    Ok(())
50}
51#[derive(Deserialize)]
52#[serde(deny_unknown_fields)]
53struct ReadArgs {
54    path: String,
55}
56#[derive(Deserialize)]
57#[serde(deny_unknown_fields)]
58struct SearchArgs {
59    path: String,
60    query: String,
61    #[serde(default = "default_limit")]
62    limit: usize,
63}
64fn default_limit() -> usize {
65    100
66}
67#[derive(Deserialize)]
68#[serde(deny_unknown_fields)]
69struct PatchArgs {
70    path: String,
71    old_text: String,
72    new_text: String,
73}
74#[derive(Deserialize)]
75#[serde(deny_unknown_fields)]
76struct CreateArgs {
77    path: String,
78    text: String,
79}
80#[derive(Deserialize)]
81#[serde(deny_unknown_fields)]
82struct ProcessArgs {
83    argv: Vec<String>,
84    #[serde(default = "default_cwd")]
85    cwd: String,
86    #[serde(default = "default_timeout")]
87    timeout_seconds: u64,
88}
89fn default_cwd() -> String {
90    ".".into()
91}
92fn default_timeout() -> u64 {
93    30
94}
95#[derive(Deserialize)]
96#[serde(deny_unknown_fields)]
97struct EmptyArgs {}
98fn relative(path: &str) -> Result<()> {
99    ensure!(
100        !path.is_empty() && path.len() <= 4096,
101        "invalid workspace path"
102    );
103    ensure!(
104        !Path::new(path).is_absolute() && !path.contains('\0'),
105        "workspace path must be relative"
106    );
107    for component in Path::new(path).components() {
108        ensure!(
109            !matches!(
110                component,
111                Component::ParentDir | Component::RootDir | Component::Prefix(_)
112            ),
113            "path traversal is forbidden"
114        );
115        ensure!(
116            !component
117                .as_os_str()
118                .to_string_lossy()
119                .eq_ignore_ascii_case(".git"),
120            "git metadata is not accessible"
121        );
122    }
123    Ok(())
124}
125fn confined(root: &Path, path: &str) -> Result<PathBuf> {
126    relative(path)?;
127    let resolved = root
128        .join(path)
129        .canonicalize()
130        .context("workspace path does not exist")?;
131    let relative = resolved
132        .strip_prefix(root)
133        .context("workspace path escapes root")?;
134    ensure!(
135        !relative
136            .components()
137            .any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git")),
138        "git metadata is not accessible"
139    );
140    Ok(resolved)
141}
142fn new_file_path(root: &Path, path: &str) -> Result<PathBuf> {
143    relative(path)?;
144    let target = root.join(path);
145    let parent = target.parent().context("workspace file has no parent")?;
146    let parent = parent
147        .canonicalize()
148        .context("workspace parent directory does not exist")?;
149    let relative_parent = parent
150        .strip_prefix(root)
151        .context("workspace parent escapes root")?;
152    let mut components = relative_parent.components();
153    let is_runtime_history = components.next().is_some_and(|component| {
154        component
155            .as_os_str()
156            .to_string_lossy()
157            .eq_ignore_ascii_case(".ctx")
158    }) && components.next().is_some_and(|component| {
159        component
160            .as_os_str()
161            .to_string_lossy()
162            .eq_ignore_ascii_case("runs")
163    });
164    ensure!(
165        !is_runtime_history,
166        "runtime history and artifacts cannot be created"
167    );
168    match std::fs::symlink_metadata(&target) {
169        Ok(_) => bail!("workspace file already exists"),
170        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
171        Err(error) => return Err(error).context("checking workspace file path"),
172    }
173    Ok(parent.join(target.file_name().context("invalid workspace filename")?))
174}
175pub(super) fn validate(root: &Path, name: &str, args: &Value) -> Result<()> {
176    match name {
177        "workspace.read" => relative(&serde_json::from_value::<ReadArgs>(args.clone())?.path)?,
178        "workspace.search" => {
179            let a: SearchArgs = serde_json::from_value(args.clone())?;
180            relative(&a.path)?;
181            ensure!(
182                !a.query.is_empty() && a.query.len() <= 8192 && (1..=100).contains(&a.limit),
183                "invalid search arguments"
184            );
185        }
186        "workspace.patch" => {
187            let a: PatchArgs = serde_json::from_value(args.clone())?;
188            writable(root, &a.path)?;
189            ensure!(
190                !a.old_text.is_empty()
191                    && a.old_text.len() <= FILE_LIMIT
192                    && a.new_text.len() <= FILE_LIMIT,
193                "invalid replacement"
194            );
195        }
196        "workspace.create" => {
197            let a: CreateArgs = serde_json::from_value(args.clone())?;
198            new_file_path(root, &a.path)?;
199            ensure!(a.text.len() <= FILE_LIMIT, "file exceeds 1 MiB limit");
200        }
201        "process.exec" => {
202            let a: ProcessArgs = serde_json::from_value(args.clone())?;
203            relative(&a.cwd)?;
204            ensure!(
205                !a.argv.is_empty()
206                    && a.argv.len() <= 128
207                    && !a.argv[0].is_empty()
208                    && a.argv.iter().all(|v| !v.contains('\0'))
209                    && a.argv.iter().map(String::len).sum::<usize>() <= 65536
210                    && (1..=60).contains(&a.timeout_seconds),
211                "invalid process arguments"
212            );
213        }
214        "git.status" | "git.diff" => {
215            let _: EmptyArgs = serde_json::from_value(args.clone())?;
216        }
217        _ => bail!("unsupported developer tool"),
218    }
219    if name != "workspace.create" {
220        if let Some(path) = args
221            .get("path")
222            .or_else(|| args.get("cwd"))
223            .and_then(Value::as_str)
224        {
225            confined(root, path)?;
226        }
227    }
228    Ok(())
229}
230
231fn writable(root: &Path, path: &str) -> Result<PathBuf> {
232    let path = confined(root, path)?;
233    let parts: Vec<_> = path.strip_prefix(root)?.components().collect();
234    ensure!(
235        !(parts.len() >= 2
236            && parts[0]
237                .as_os_str()
238                .to_string_lossy()
239                .eq_ignore_ascii_case(".ctx")
240            && parts[1]
241                .as_os_str()
242                .to_string_lossy()
243                .eq_ignore_ascii_case("runs")),
244        "runtime history and artifacts cannot be patched"
245    );
246    Ok(path)
247}
248fn read_file(path: &Path) -> Result<String> {
249    use std::io::Read;
250    ensure!(std::fs::metadata(path)?.is_file(), "expected regular file");
251    let file = std::fs::File::open(path)?;
252    ensure!(file.metadata()?.is_file(), "expected regular file");
253    let mut bytes = Vec::new();
254    file.take((FILE_LIMIT + 1) as u64).read_to_end(&mut bytes)?;
255    ensure!(bytes.len() <= FILE_LIMIT, "file exceeds 1 MiB limit");
256    String::from_utf8(bytes).context("expected UTF-8 text")
257}
258async fn bounded_read(reader: impl tokio::io::AsyncRead + Unpin) -> Result<Vec<u8>> {
259    let mut output = Vec::new();
260    reader
261        .take((OUTPUT_LIMIT + 1) as u64)
262        .read_to_end(&mut output)
263        .await?;
264    ensure!(output.len() <= OUTPUT_LIMIT, "process output exceeds limit");
265    Ok(output)
266}
267async fn process(mut cmd: Command, seconds: u64) -> Result<Value> {
268    cmd.stdin(Stdio::null())
269        .stdout(Stdio::piped())
270        .stderr(Stdio::piped())
271        .kill_on_drop(true);
272    let mut child = cmd.spawn().context("could not start process")?;
273    let stdout = child.stdout.take().context("missing stdout")?;
274    let stderr = child.stderr.take().context("missing stderr")?;
275    let result = tokio::time::timeout(Duration::from_secs(seconds), async {
276        tokio::try_join!(
277            async { Ok::<_, anyhow::Error>(child.wait().await?) },
278            bounded_read(stdout),
279            bounded_read(stderr)
280        )
281    })
282    .await;
283    match result {
284        Ok(Ok((status, stdout, stderr))) => Ok(
285            json!({"exit_code":status.code(), "success":status.success(), "stdout":String::from_utf8_lossy(&stdout), "stderr":String::from_utf8_lossy(&stderr)}),
286        ),
287        other => {
288            let _ = child.kill().await;
289            match other {
290                Err(_) => bail!("process timed out"),
291                Ok(Err(error)) => Err(error),
292                _ => unreachable!(),
293            }
294        }
295    }
296}
297fn git_command(root: &Path) -> Command {
298    let mut cmd = Command::new("git");
299    cmd.env_clear()
300        .env("PATH", std::env::var_os("PATH").unwrap_or_default())
301        .env("GIT_CONFIG_NOSYSTEM", "1")
302        .env("GIT_CONFIG_GLOBAL", "/dev/null")
303        .env("GIT_OPTIONAL_LOCKS", "0")
304        .env("GIT_TERMINAL_PROMPT", "0")
305        .current_dir(root);
306    cmd.arg("--work-tree")
307        .arg(root)
308        .arg("--git-dir")
309        .arg(root.join(".git"));
310    cmd.args([
311        "--no-pager",
312        "--no-optional-locks",
313        "-c",
314        "core.fsmonitor=false",
315        "-c",
316        "core.untrackedCache=false",
317        "-c",
318        "core.hooksPath=/dev/null",
319        "-c",
320        "core.attributesFile=/dev/null",
321        "-c",
322        "diff.external=",
323        "-c",
324        "core.pager=cat",
325    ]);
326    cmd
327}
328struct DeveloperTool {
329    name: &'static str,
330    root: PathBuf,
331}
332#[async_trait]
333impl Tool for DeveloperTool {
334    fn name(&self) -> &str {
335        self.name
336    }
337    fn description(&self) -> &str {
338        match self.name {
339            "workspace.read" => "Read a UTF-8 workspace file (maximum 1 MiB)",
340            "workspace.search" => {
341                "Find literal text in workspace files; skips symlinks and git metadata"
342            }
343            "git.status" => "Read workspace Git status without optional index writes or hooks",
344            "git.diff" => {
345                "Read unstaged workspace Git diff without external diff or text conversion"
346            }
347            "workspace.patch" => {
348                "Replace one unique old_text with new_text in an existing workspace UTF-8 file"
349            }
350            "workspace.create" => {
351                "Create a new UTF-8 workspace file without overwriting an existing file"
352            }
353            _ => {
354                "Execute explicit argv in a workspace directory; UNSANDBOXED with host permissions"
355            }
356        }
357    }
358    fn capabilities(&self) -> Option<Vec<Capability>> {
359        capability(self.name).map(|c| vec![c])
360    }
361    fn binding_metadata(&self) -> Option<Value> {
362        Some(tool_binding::compatibility_binding_metadata(
363            self.name(),
364            &format!("builtin.{}", self.name),
365            self.parameters_schema(),
366            self.capabilities().unwrap(),
367            ToolTrustClass::Builtin,
368        ))
369    }
370    fn is_builtin(&self) -> bool {
371        true
372    }
373    fn parameters_schema(&self) -> Value {
374        let (properties, required) = match self.name {
375            "workspace.read" => (json!({"path":{"type":"string"}}), json!(["path"])),
376            "workspace.search" => (
377                json!({"path":{"type":"string"},"query":{"type":"string","minLength":1},"limit":{"type":"integer","minimum":1,"maximum":100,"default":100}}),
378                json!(["path", "query"]),
379            ),
380            "workspace.patch" => (
381                json!({"path":{"type":"string"},"old_text":{"type":"string","minLength":1},"new_text":{"type":"string"}}),
382                json!(["path", "old_text", "new_text"]),
383            ),
384            "workspace.create" => (
385                json!({"path":{"type":"string"},"text":{"type":"string"}}),
386                json!(["path", "text"]),
387            ),
388            "process.exec" => (
389                json!({"argv":{"type":"array","minItems":1,"maxItems":128,"items":{"type":"string"}},"cwd":{"type":"string","default":"."},"timeout_seconds":{"type":"integer","minimum":1,"maximum":60,"default":30}}),
390                json!(["argv"]),
391            ),
392            _ => (json!({}), json!([])),
393        };
394        json!({"type":"object","properties":properties,"required":required,"additionalProperties":false})
395    }
396    fn validate_arguments(&self, arguments: &Value) -> Result<()> {
397        validate(&self.root, self.name, arguments)
398    }
399    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<Value> {
400        validate(&self.root, self.name, &args)?;
401        self.execute_validated(args).await
402    }
403}
404impl DeveloperTool {
405    async fn execute_validated(&self, args: Value) -> Result<Value> {
406        match self.name {
407            "workspace.create" => {
408                let a: CreateArgs = serde_json::from_value(args)?;
409                let path = new_file_path(&self.root, &a.path)?;
410                let mut file = std::fs::OpenOptions::new()
411                    .write(true)
412                    .create_new(true)
413                    .open(&path)
414                    .context("creating new workspace file")?;
415                use std::io::Write;
416                file.write_all(a.text.as_bytes())
417                    .context("writing new workspace file")?;
418                Ok(json!({"path":a.path,"created":true,"bytes_written":a.text.len()}))
419            }
420            "workspace.read" => {
421                let a: ReadArgs = serde_json::from_value(args)?;
422                let path = confined(&self.root, &a.path)?;
423                Ok(json!({"path":a.path,"text":read_file(&path)?}))
424            }
425            "workspace.search" => {
426                let a: SearchArgs = serde_json::from_value(args)?;
427                let path = confined(&self.root, &a.path)?;
428                let mut matches = Vec::new();
429                let mut scanned = 0usize;
430                let mut bytes_scanned = 0usize;
431                let mut truncated = false;
432                let entries = walkdir::WalkDir::new(path)
433                    .follow_links(false)
434                    .into_iter()
435                    .filter_entry(|e| {
436                        !e.file_name().to_string_lossy().eq_ignore_ascii_case(".git")
437                    });
438                for (visited, entry) in entries.enumerate() {
439                    if visited >= 10000
440                        || scanned >= 1000
441                        || bytes_scanned >= 16 * FILE_LIMIT
442                        || matches.len() >= a.limit
443                    {
444                        truncated = true;
445                        break;
446                    }
447                    let entry = entry?;
448                    if !entry.file_type().is_file() {
449                        continue;
450                    }
451                    let relative = entry
452                        .path()
453                        .strip_prefix(&self.root)?
454                        .to_str()
455                        .context("non-UTF8 file path")?;
456                    let checked = confined(&self.root, relative)?;
457                    tokio::task::yield_now().await;
458                    scanned += 1;
459                    let Ok(text) = read_file(&checked) else {
460                        continue;
461                    };
462                    bytes_scanned += text.len();
463                    for (line, content) in text.lines().enumerate() {
464                        if content.contains(&a.query) {
465                            // Bound each snippet by characters so UTF-8 is never split.
466                            matches.push(json!({"path":relative,"line":line + 1,"text":content.chars().take(1000).collect::<String>()}));
467                            if matches.len() >= a.limit {
468                                truncated = true;
469                                break;
470                            }
471                        }
472                    }
473                }
474                Ok(json!({"matches":matches,"files_scanned":scanned,"truncated":truncated}))
475            }
476            "workspace.patch" => {
477                let a: PatchArgs = serde_json::from_value(args)?;
478                let path = writable(&self.root, &a.path)?;
479                #[cfg(unix)]
480                {
481                    use std::os::unix::fs::MetadataExt;
482                    ensure!(
483                        std::fs::metadata(&path)?.nlink() == 1,
484                        "cannot patch hard-linked files"
485                    );
486                }
487                let old = read_file(&path)?;
488                let first = old
489                    .find(&a.old_text)
490                    .context("old_text must match exactly once")?;
491                let next = first
492                    + a.old_text
493                        .chars()
494                        .next()
495                        .context("empty old_text")?
496                        .len_utf8();
497                ensure!(
498                    !old[next..].contains(&a.old_text),
499                    "old_text must match exactly once"
500                );
501                let new = old.replacen(&a.old_text, &a.new_text, 1);
502                ensure!(new.len() <= FILE_LIMIT, "replacement exceeds file limit");
503                // Synchronous replacement completes without an async cancellation point.
504                std::fs::write(&path, new.as_bytes())?;
505                Ok(json!({"path":a.path,"bytes_written":new.len()}))
506            }
507            "process.exec" => {
508                let a: ProcessArgs = serde_json::from_value(args)?;
509                let cwd = confined(&self.root, &a.cwd)?;
510                ensure!(cwd.is_dir(), "cwd must be a directory");
511                let mut cmd = Command::new(&a.argv[0]);
512                cmd.args(&a.argv[1..]).current_dir(cwd);
513                process(cmd, a.timeout_seconds).await
514            }
515            _ => {
516                ensure!(
517                    self.root.join(".git").exists(),
518                    "Git tools require a workspace repository"
519                );
520                // Git may run clean/process filters even during status/diff.
521                // Enumerate effective filter keys (including local includes) and
522                // override them, along with fsmonitor and external diff helpers.
523                let mut config = git_command(&self.root);
524                config.args([
525                    "config",
526                    "--includes",
527                    "--name-only",
528                    "--get-regexp",
529                    "^filter\\..*\\.(clean|process|required)$",
530                ]);
531                let filters = process(config, 10).await?;
532                ensure!(
533                    matches!(filters["exit_code"].as_i64(), Some(0 | 1)),
534                    "could not inspect Git filters"
535                );
536                let mut cmd = git_command(&self.root);
537                for key in filters["stdout"].as_str().unwrap_or("").lines() {
538                    ensure!(
539                        key.starts_with("filter.") && !key.contains('=') && !key.contains('\0'),
540                        "invalid Git filter key"
541                    );
542                    let value = if key.ends_with(".required") {
543                        "false"
544                    } else {
545                        ""
546                    };
547                    cmd.arg("-c").arg(format!("{key}={value}"));
548                }
549                if self.name == "git.status" {
550                    cmd.args([
551                        "status",
552                        "--porcelain=v1",
553                        "--untracked-files=normal",
554                        "--ignore-submodules=all",
555                    ]);
556                } else {
557                    cmd.args([
558                        "diff",
559                        "--no-ext-diff",
560                        "--no-textconv",
561                        "--ignore-submodules=all",
562                        "--no-color",
563                        "--",
564                    ]);
565                }
566                process(cmd, 30).await
567            }
568        }
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    fn tool(name: &'static str, root: &Path) -> DeveloperTool {
576        DeveloperTool {
577            name,
578            root: root.canonicalize().unwrap(),
579        }
580    }
581    #[test]
582    fn strict_arguments() {
583        for (name, args) in [
584            ("workspace.read", json!({"path":"../secret"})),
585            ("workspace.read", json!({"path":"/etc/passwd"})),
586            ("workspace.read", json!({"path":".git/config"})),
587            (
588                "workspace.search",
589                json!({"path":".","query":"x","limit":0}),
590            ),
591            ("process.exec", json!({"argv":[]})),
592            ("process.exec", json!({"argv":["echo"],"shell":true})),
593            ("git.diff", json!({"path":"x"})),
594        ] {
595            assert!(validate(Path::new("."), name, &args).is_err());
596        }
597    }
598    #[tokio::test]
599    async fn reads_searches_and_patches_unique_text() {
600        let root = tempfile::tempdir().unwrap();
601        std::fs::write(root.path().join("sample"), "hello world\nnext\n").unwrap();
602        let result = tool("workspace.read", root.path())
603            .execute_validated(json!({"path":"sample"}))
604            .await
605            .unwrap();
606        assert_eq!(result["text"], "hello world\nnext\n");
607        let result = tool("workspace.search", root.path())
608            .execute_validated(json!({"path":".","query":"world"}))
609            .await
610            .unwrap();
611        assert_eq!(result["matches"][0]["line"], 1);
612        tool("workspace.patch", root.path())
613            .execute_validated(json!({"path":"sample","old_text":"world","new_text":"workspace"}))
614            .await
615            .unwrap();
616        assert_eq!(
617            std::fs::read_to_string(root.path().join("sample")).unwrap(),
618            "hello workspace\nnext\n"
619        );
620        assert!(tool("workspace.patch", root.path())
621            .execute_validated(json!({"path":"sample","old_text":"missing","new_text":"x"}))
622            .await
623            .is_err());
624        let created = tool("workspace.create", root.path())
625            .execute_validated(json!({"path":"new-page.md","text":"# New page\n"}))
626            .await
627            .unwrap();
628        assert_eq!(created["created"], true);
629        assert_eq!(
630            std::fs::read_to_string(root.path().join("new-page.md")).unwrap(),
631            "# New page\n"
632        );
633        assert!(tool("workspace.create", root.path())
634            .execute_validated(json!({"path":"new-page.md","text":"overwrite"}))
635            .await
636            .is_err());
637        assert!(validate(
638            root.path(),
639            "workspace.create",
640            &json!({"path":"missing-dir/page.md", "text":"x"})
641        )
642        .is_err());
643        std::fs::write(root.path().join("overlap"), "aaa").unwrap();
644        assert!(tool("workspace.patch", root.path())
645            .execute_validated(json!({"path":"overlap","old_text":"aa","new_text":"x"}))
646            .await
647            .is_err());
648        std::fs::create_dir_all(root.path().join(".ctx/runs")).unwrap();
649        std::fs::write(root.path().join(".ctx/runs/history"), "old").unwrap();
650        assert!(validate(
651            root.path(),
652            "workspace.patch",
653            &json!({"path":".ctx/runs/history", "old_text":"old", "new_text":"new"})
654        )
655        .is_err());
656        assert!(validate(
657            root.path(),
658            "workspace.create",
659            &json!({"path":".ctx/runs/new", "text":"blocked"})
660        )
661        .is_err());
662        std::fs::write(root.path().join("big"), vec![b'a'; FILE_LIMIT + 1]).unwrap();
663        assert!(tool("workspace.read", root.path())
664            .execute_validated(json!({"path":"big"}))
665            .await
666            .is_err());
667    }
668    #[cfg(unix)]
669    #[tokio::test]
670    async fn refuses_symlink_escapes_and_hardlink_writes() {
671        use std::os::unix::fs::symlink;
672        let root = tempfile::tempdir().unwrap();
673        let outside = tempfile::tempdir().unwrap();
674        let secret = outside.path().join("secret");
675        std::fs::write(&secret, "secret").unwrap();
676        symlink(&secret, root.path().join("escape")).unwrap();
677        assert!(tool("workspace.read", root.path())
678            .execute_validated(json!({"path":"escape"}))
679            .await
680            .is_err());
681        std::fs::hard_link(&secret, root.path().join("hardlink")).unwrap();
682        assert!(tool("workspace.patch", root.path())
683            .execute_validated(json!({"path":"hardlink","old_text":"secret","new_text":"changed"}))
684            .await
685            .is_err());
686        assert_eq!(std::fs::read_to_string(secret).unwrap(), "secret");
687    }
688    #[cfg(unix)]
689    #[tokio::test]
690    async fn process_reports_exit_and_bounds_time_and_output() {
691        let root = tempfile::tempdir().unwrap();
692        let exec = tool("process.exec", root.path());
693        let result = exec
694            .execute_validated(
695                json!({"argv":["/bin/sh","-c","printf output; printf error >&2; exit 7"]}),
696            )
697            .await
698            .unwrap();
699        assert_eq!(result["stdout"], "output");
700        assert_eq!(result["stderr"], "error");
701        assert_eq!(result["exit_code"], 7);
702        assert!(exec
703            .execute_validated(json!({"argv":["/bin/sleep","3"],"timeout_seconds":1}))
704            .await
705            .unwrap_err()
706            .to_string()
707            .contains("timed out"));
708        assert!(exec
709            .execute_validated(json!({"argv":["/usr/bin/yes"]}))
710            .await
711            .unwrap_err()
712            .to_string()
713            .contains("output exceeds"));
714    }
715    #[cfg(unix)]
716    #[tokio::test]
717    async fn git_disables_clean_filters_and_handles_worktrees() {
718        let root = tempfile::tempdir().unwrap();
719        let git = |args: &[&str]| {
720            let result = std::process::Command::new("git")
721                .current_dir(root.path())
722                .args(args)
723                .output()
724                .unwrap();
725            assert!(
726                result.status.success(),
727                "{}",
728                String::from_utf8_lossy(&result.stderr)
729            );
730        };
731        git(&["init", "-q"]);
732        git(&["config", "user.email", "test@example.com"]);
733        git(&["config", "user.name", "Test"]);
734        std::fs::write(root.path().join("sample"), "original").unwrap();
735        git(&["add", "sample"]);
736        git(&["-c", "commit.gpgsign=false", "commit", "-qm", "initial"]);
737        std::fs::write(
738            root.path().join(".gitattributes"),
739            "sample filter=unsafe diff=unsafe\n",
740        )
741        .unwrap();
742        git(&[
743            "config",
744            "filter.unsafe.clean",
745            "touch SHOULD_NOT_EXIST; cat",
746        ]);
747        git(&["config", "filter.unsafe.required", "true"]);
748        git(&["config", "diff.unsafe.command", "touch SHOULD_NOT_EXIST"]);
749        git(&["config", "core.fsmonitor", "touch SHOULD_NOT_EXIST"]);
750        std::fs::write(root.path().join("sample"), "changed").unwrap();
751        for name in ["git.status", "git.diff"] {
752            let output = tool(name, root.path())
753                .execute_validated(json!({}))
754                .await
755                .unwrap();
756            assert_eq!(output["success"], true, "{output}");
757            assert!(!root.path().join("SHOULD_NOT_EXIST").exists());
758        }
759        git(&["config", "--unset", "core.fsmonitor"]);
760        let linked = root.path().join("linked");
761        git(&["worktree", "add", "--detach", linked.to_str().unwrap()]);
762        let output = tool("git.status", &linked)
763            .execute_validated(json!({}))
764            .await
765            .unwrap();
766        assert_eq!(output["success"], true, "{output}");
767    }
768    #[cfg(unix)]
769    #[test]
770    fn refuses_fifo_without_opening_it() {
771        let root = tempfile::tempdir().unwrap();
772        let fifo = root.path().join("fifo");
773        assert!(std::process::Command::new("mkfifo")
774            .arg(&fifo)
775            .status()
776            .unwrap()
777            .success());
778        assert!(read_file(&fifo)
779            .unwrap_err()
780            .to_string()
781            .contains("regular file"));
782    }
783}