1use 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 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 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 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}