1use serde::Serialize;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "kebab-case")]
59pub enum RuntimeWriteKind {
60 File,
61 Directory,
62}
63
64impl RuntimeWriteKind {
65 #[must_use]
67 pub fn wire_name(self) -> &'static str {
68 match self {
69 Self::File => "file",
70 Self::Directory => "directory",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum RuntimeWriteCondition {
79 ScriptSession,
81 PeerFetch,
83}
84
85impl RuntimeWriteCondition {
86 #[must_use]
88 pub fn wire_name(self) -> &'static str {
89 match self {
90 Self::ScriptSession => "script-session",
91 Self::PeerFetch => "peer-fetch",
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100pub struct RuntimeWriteReport {
101 pub path: String,
103 pub kind: RuntimeWriteKind,
104 pub condition: RuntimeWriteCondition,
105}
106
107struct DeclaredWrite {
109 entry: &'static str,
110 kind: RuntimeWriteKind,
111 condition: RuntimeWriteCondition,
112}
113
114impl DeclaredWrite {
115 fn report(&self) -> RuntimeWriteReport {
116 RuntimeWriteReport {
117 path: self.entry.to_string(),
118 kind: self.kind,
119 condition: self.condition,
120 }
121 }
122
123 #[cfg(test)]
126 fn first_segment(&self) -> &'static str {
127 self.entry.split('/').next().unwrap_or(self.entry)
128 }
129}
130
131const DECLARED_WRITES: &[DeclaredWrite] = &[
137 DeclaredWrite {
138 entry: "peer-in",
141 kind: RuntimeWriteKind::Directory,
142 condition: RuntimeWriteCondition::PeerFetch,
143 },
144 DeclaredWrite {
145 entry: "out",
148 kind: RuntimeWriteKind::Directory,
149 condition: RuntimeWriteCondition::ScriptSession,
150 },
151];
152
153pub(crate) fn runtime_writes() -> Vec<RuntimeWriteReport> {
159 DECLARED_WRITES.iter().map(DeclaredWrite::report).collect()
160}
161
162#[must_use]
168pub fn render_runtime_writes(writes: &[RuntimeWriteReport]) -> String {
169 let mut out = String::new();
170 out.push_str(" paths are relative to the work folder\n");
171 if writes.is_empty() {
172 out.push_str(" runtime writes: <none>\n");
173 return out;
174 }
175 out.push_str(" runtime writes:\n");
176 for write in writes {
177 out.push_str(&format!(
178 " - {} ({}, {})\n",
179 write.path,
180 write.kind.wire_name(),
181 write.condition.wire_name()
182 ));
183 }
184 out
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 const SCANNED: &[(&str, &str)] = &[
201 ("agent.rs", include_str!("agent.rs")),
202 ("agent/inventory.rs", include_str!("agent/inventory.rs")),
203 ("agent/process.rs", include_str!("agent/process.rs")),
204 ("child_launch.rs", include_str!("child_launch.rs")),
205 ("delegation.rs", include_str!("delegation.rs")),
206 ("delegation_plane.rs", include_str!("delegation_plane.rs")),
207 ("detached.rs", include_str!("detached.rs")),
208 ("hooks.rs", include_str!("hooks.rs")),
209 ("instruction_files.rs", include_str!("instruction_files.rs")),
210 ("murmur_md.rs", include_str!("murmur_md.rs")),
211 ("otel.rs", include_str!("otel.rs")),
212 ("network_policy.rs", include_str!("network_policy.rs")),
213 ("plan.rs", include_str!("plan.rs")),
214 ("protected_paths.rs", include_str!("protected_paths.rs")),
215 ("recipes.rs", include_str!("recipes.rs")),
216 ("records_dir.rs", include_str!("records_dir.rs")),
217 ("resource_plane.rs", include_str!("resource_plane.rs")),
218 ("retention.rs", include_str!("retention.rs")),
219 ("runtime.rs", include_str!("runtime.rs")),
220 (
221 "runtime/artifact_change.rs",
222 include_str!("runtime/artifact_change.rs"),
223 ),
224 ("runtime_dir.rs", include_str!("runtime_dir.rs")),
225 ("sandbox.rs", include_str!("sandbox.rs")),
226 ("sealed.rs", include_str!("sealed.rs")),
227 ("shell.rs", include_str!("shell.rs")),
228 ("session_check.rs", include_str!("session_check.rs")),
229 ("tool_annotations.rs", include_str!("tool_annotations.rs")),
230 ("trace.rs", include_str!("trace.rs")),
231 ("trace_blobs.rs", include_str!("trace_blobs.rs")),
232 ("types.rs", include_str!("types.rs")),
233 ("workdir_snapshot.rs", include_str!("workdir_snapshot.rs")),
234 ];
235
236 const CONSTANTS: &[(&str, &str)] = &[
242 ("MANIFEST_FILENAME", murmur_artifact::MANIFEST_FILENAME),
243 ("COMPLETION_FILE", crate::delegation::COMPLETION_FILE),
244 ("BLOB_DIR_NAME", crate::trace_blobs::BLOB_DIR_NAME),
245 ("TASK_INPUT_FILE", crate::runtime::TASK_INPUT_FILE),
246 (
247 "LEGACY_TASK_INPUT_FILE",
248 crate::runtime::LEGACY_TASK_INPUT_FILE,
249 ),
250 ("TRACE_FILE_NAME", crate::trace::TRACE_FILE_NAME),
251 ("CHILDREN_DIR", crate::child_launch::CHILDREN_DIR),
252 ("DELEGATIONS_SUBDIR", crate::runtime_dir::DELEGATIONS_SUBDIR),
253 ("HOOKS_SUBDIR", crate::records_dir::HOOKS_SUBDIR),
254 ("HOME_SUBDIR", crate::records_dir::HOME_SUBDIR),
255 ("CHECK_LOG_FILE", crate::session_check::CHECK_LOG_FILE),
256 ("RESULTS_SUBDIR", crate::records_dir::RESULTS_SUBDIR),
257 ("RESULT_FILE", crate::records_dir::RESULT_FILE),
258 (
259 "SESSION_TEMP_DIR_NAME",
260 crate::temp_dir::SESSION_TEMP_DIR_NAME,
261 ),
262 ];
263
264 const EXEMPT: &[(&str, &str)] = &[
266 (
267 "input.txt",
268 "Read, never written: the operator's task file `import_task_input` reads once when \
269 the workdir has no `task.md`. Source text cannot tell a read from a write, so a \
270 path the runtime only ever opens for reading is exempted here rather than declared \
271 as something the runtime produces.",
272 ),
273 (
274 "package.json",
275 "Read, never written: the recipe detector opens it to learn which scripts a workdir \
276 already declares. Exempt on `input.txt`'s terms.",
277 ),
278 ];
279
280 const DYNAMIC_EXEMPT: &[(&str, &str, &str)] = &[
283 (
284 "protected_paths.rs",
285 "&rule.relative",
286 "A `capabilities.filesystem.read_only` path from the manifest, resolved to decide \
287 whether a call is refused. The runtime reads it and writes nothing.",
288 ),
289 (
290 "protected_paths.rs",
291 "&lexical",
292 "The lexically normalised form of a path a tool call named, resolved for the same \
293 refusal decision.",
294 ),
295 (
296 "resource_plane.rs",
297 "declared_root",
298 "The `exports.files` / `exports.peer_files` root a manifest declares. The plane \
299 serves what the capsule put there; it creates nothing of its own.",
300 ),
301 (
302 "resource_plane.rs",
303 "&export.root",
304 "The same declared root, resolved when a request is served. Exempt on \
305 `declared_root`'s terms.",
306 ),
307 (
308 "runtime.rs",
309 "&stored_path",
310 "Where a redeemed peer handle's bytes go, composed by `peer_handoff::stored_path_for` \
311 under `peer-in/`. Declared through that directory's own entry, which is the name a \
312 consumer excludes.",
313 ),
314 (
315 "runtime.rs",
316 "rel",
317 "`write_staged_file`'s path, `tools/<name>/<file>` in the runtime directory, composed \
318 by each of its callers. Declared through RUNTIME_DIR_WRITES' `tools` entry.",
319 ),
320 (
321 "session_check.rs",
322 "rel.as_ref()",
323 "A file or directory of an eval case's `start` or `check.files`, copied into the work \
324 folder by `copy_tree_into`: `start` by `mur eval run` into a freshly staged work \
325 folder before launch, as an input it stages for the capsule; `check.files` by \
326 `CheckRunner::run` after the session has ended and before the check's command runs. \
327 No session writes either.",
328 ),
329 (
330 "types.rs",
331 "rel",
332 "`StagedSession::write_workdir_file`'s path, named by the embedding caller for an \
333 input it stages for the capsule before launch. A launch writes nothing there of its \
334 own.",
335 ),
336 (
337 "trace.rs",
338 "trace",
339 "A resumed trace's `trace.jsonl`, in the directory `mur run --resume` named it in \
340 (`ResumeRequest::trace_dir`): the prior session's records directory, or the folder \
341 its trace was copied to. Only `shell_lost` markers are appended.",
342 ),
343 (
344 "runtime.rs",
345 "&name",
346 "The launched manifest's own file name, copied into the runtime directory through \
347 `runtime_root`. Declared through RUNTIME_DIR_WRITES' `murmur.yaml` entry, the \
348 project manifest's name.",
349 ),
350 (
351 "records_dir.rs",
352 "&rel",
353 "`<workdir root mirrored>/<session-id>` beneath `~/.murmur/records`, created at staging \
354 outside every workdir.",
355 ),
356 ];
357
358 fn production_text(source: &str) -> &str {
364 match source.find("#[cfg(test)]\nmod tests {") {
365 Some(index) => &source[..index],
366 None => source,
367 }
368 }
369
370 fn parenthesised(source: &str, open: usize) -> &str {
372 let bytes = source.as_bytes();
373 let mut depth = 1usize;
374 let mut index = open;
375 while index < bytes.len() && depth > 0 {
376 match bytes[index] {
377 b'(' => depth += 1,
378 b')' => depth -= 1,
379 _ => {}
380 }
381 index += 1;
382 }
383 assert!(depth == 0, "unbalanced parentheses in a scanned call");
384 &source[open..index - 1]
385 }
386
387 fn one_line(text: &str) -> String {
390 text.split_whitespace().collect::<Vec<_>>().join(" ")
391 }
392
393 fn first_argument(arguments: &str) -> &str {
395 let mut depth = 0i32;
396 let mut in_string = false;
397 for (index, character) in arguments.char_indices() {
398 match character {
399 '"' => in_string = !in_string,
400 '(' | '[' | '{' if !in_string => depth += 1,
401 ')' | ']' | '}' if !in_string => depth -= 1,
402 ',' if !in_string && depth == 0 => return &arguments[..index],
403 _ => {}
404 }
405 }
406 arguments
407 }
408
409 fn workdir_join_arguments(source: &str) -> Vec<String> {
415 let mut found = Vec::new();
416 let mut cursor = 0;
417 while let Some(offset) = source[cursor..].find(".join(") {
418 let start = cursor + offset;
419 cursor = start + ".join(".len();
420 if !source[..start].trim_end().ends_with("workdir") {
421 continue;
422 }
423 found.push(one_line(parenthesised(source, cursor)));
424 }
425 found
426 }
427
428 const HELPER_WRITES: &[&str] = &[
431 ".write_file(",
432 ".write_file_exact_mode(",
433 ".overwrite_in_place(",
434 ".open_append(",
435 ".create_dir_all(",
436 ".set_dir_mode(",
437 ];
438
439 fn helper_write_arguments(source: &str) -> Vec<String> {
444 helper_calls(source, HELPER_WRITES)
445 .into_iter()
446 .map(|(_, argument)| argument)
447 .collect()
448 }
449
450 fn helper_calls(source: &str, methods: &[&str]) -> Vec<(String, String)> {
455 helper_calls_at(source, methods)
456 .into_iter()
457 .map(|(_, receiver, argument)| (receiver, argument))
458 .collect()
459 }
460
461 fn helper_calls_at(source: &str, methods: &[&str]) -> Vec<(usize, String, String)> {
463 let mut found = Vec::new();
464 for method in methods {
465 let mut cursor = 0;
466 while let Some(offset) = source[cursor..].find(method) {
467 let start = cursor + offset;
468 cursor = start + method.len();
469 if source[..start].ends_with("fs") {
470 continue;
471 }
472 let receiver: String = source[..start]
473 .trim_end()
474 .chars()
475 .rev()
476 .take_while(|character| character.is_ascii_alphanumeric() || *character == '_')
477 .collect::<Vec<_>>()
478 .into_iter()
479 .rev()
480 .collect();
481 found.push((
482 start,
483 receiver,
484 one_line(first_argument(parenthesised(source, cursor))),
485 ));
486 }
487 }
488 found
489 }
490
491 const RUNTIME_DIR_ROOT: &str = "runtime_root";
494
495 const RECORDS_DIR_ROOT: &str = "records_root";
498
499 const RECORDS_DIR_WRITES: &[(&str, &str)] = &[
501 (
502 "trace.jsonl",
503 "The session's trace: every appender, and a resumed session's `shell_lost` markers.",
504 ),
505 (
506 "blobs",
507 "Trace bodies too large to inline, by their sha256.",
508 ),
509 (
510 "logs",
511 "`bootstrap.log`, `otel.log`, one `hook-<name>.log` per failing hook, and a shell \
512 command's full output: `shell-<ms>.log` for a truncated foreground command and \
513 `<work-id>.log` for a demoted one, and `check.log`, an eval check's output, written \
514 after the session ended.",
515 ),
516 (
517 "completion.json",
518 "A delegated child's record of how it ended, written by the child and by its \
519 parent's watcher.",
520 ),
521 (
522 "hooks",
523 "The `\".\"` of every hook declared `capabilities.filesystem.records: true`.",
524 ),
525 (
526 "tmp",
527 "The session's temporary folder, `/tmp` and `$TMPDIR` to its commands. Created \
528 empty at staging; the runtime writes nothing in it.",
529 ),
530 (
531 "home",
532 "The session's home folder, `$HOME` to its commands. Created empty at staging; the \
533 runtime writes nothing in it.",
534 ),
535 (
536 "out",
537 "An agent's `result.txt`, `result_<task-id>.txt` and `compaction-summaries.jsonl`, \
538 written by the runtime alone.",
539 ),
540 (
541 "children",
542 "One `<capsule>-<16 hex>` work folder per delegated child, the child's `--workdir`.",
543 ),
544 ];
545
546 const RECORDS_DIR_DYNAMIC: &[(&str, &str, &str, &str)] = &[
550 (
551 "child_launch.rs",
552 "rel",
553 "children",
554 "One delegated child's work folder, `children/<name>-<16 hex>`, composed by \
555 `child_workdir_rel`.",
556 ),
557 (
558 "shell.rs",
559 "&log.relative",
560 "logs",
561 "A foreground shell command's full-output log, `logs/shell-<ms>.log`, composed by \
562 `detached::output_path_for`.",
563 ),
564 (
565 "detached.rs",
566 "relative",
567 "logs",
568 "A demoted shell command's log, `logs/<work-id>.log`, composed by \
569 `detached::output_path_for` and written as the command runs.",
570 ),
571 ];
572
573 fn literal_prefix(argument: &str) -> Option<&str> {
576 let argument = argument.trim_start_matches('&');
577 let argument = argument
578 .strip_prefix("format!(")
579 .map(str::trim_start)
580 .unwrap_or(argument);
581 let (literal, _) = argument.strip_prefix('"')?.split_once('"')?;
582 Some(literal.split('{').next().unwrap_or(literal))
583 }
584
585 fn names_a_log(argument: &str) -> bool {
587 literal_prefix(argument)
588 .is_some_and(|literal| literal == "logs" || literal.starts_with("logs/"))
589 }
590
591 const OUTSIDE_WORKDIR_ROOTS: &[&str] = &["sealed_etc_root", "harness_root", "session_dir_root"];
595
596 const RUNTIME_DIR_WRITES: &[(&str, &str)] = &[
599 (
600 "tools",
601 "Every staged and pulled artifact's manifest, `skill.md` and native entrypoint, and \
602 every runtime-provided tool's manifest.",
603 ),
604 (
605 "MURMUR.md",
606 "The environment reference, written at staging and after port binding.",
607 ),
608 (
609 "task.md",
610 "The task: `mur run --task`, the import from the workdir, every A2A message and \
611 every reopen's rewrite.",
612 ),
613 (
614 "plans",
615 "One `plan-<n>.json` per submitted plan, read back when it runs.",
616 ),
617 (
618 "murmur.yaml",
619 "The project manifest, copied on every launch.",
620 ),
621 (
622 "delegations",
623 "One `<delegation-id>.txt` per sub-capsule whose answer was cut in the report its \
624 parent was handed: the whole answer, written once by `hand_over_answer`.",
625 ),
626 ];
627
628 const MOVED_OUT_OF_THE_WORKDIR: &[&str] = &[
631 "tools",
632 "MURMUR.md",
633 "task.md",
634 "plans",
635 "murmur.yaml",
636 ".mur-etc",
637 ".process-driver",
638 "trace.jsonl",
639 "blobs",
640 "completion.json",
641 "eval.jsonl",
642 ];
643
644 fn root_of(entry: &str) -> &'static str {
647 if RECORDS_DIR_WRITES.iter().any(|(name, _)| *name == entry) || entry == "eval.jsonl" {
648 RECORDS_DIR_ROOT
649 } else {
650 RUNTIME_DIR_ROOT
651 }
652 }
653
654 const MOVED_NAME_WORKDIR_READS: &[(&str, &str, &str)] = &[(
656 "runtime.rs",
657 "import_task_input",
658 "The one read of the operator's `task.md` from the workdir, at session start, copied \
659 into the runtime directory and never read again.",
660 )];
661
662 const HELPER_READS: &[&str] = &[
664 ".read_file(",
665 ".read_to_string(",
666 ".list_dir(",
667 ".file_len(",
668 ".entry_kind(",
669 ".remove_file(",
670 ".remove_dir_all(",
671 ".open_beneath(",
672 ];
673
674 fn resolve_entry_name(argument: &str) -> Option<String> {
681 let argument = argument.trim_start_matches('&');
682 let argument = argument
683 .strip_prefix("format!(")
684 .map(str::trim_start)
685 .unwrap_or(argument);
686 let resolved = if let Some(rest) = argument.strip_prefix('"') {
687 let (literal, _) = rest.split_once('"')?;
688 match literal.strip_prefix('{') {
689 Some(inner) => resolve_constant(argument, inner.split_once('}')?.0)?,
690 None if literal.starts_with('{') => return None,
691 None => literal.split('{').next()?.to_string(),
692 }
693 } else {
694 resolve_constant(argument, argument.rsplit("::").next().unwrap_or(argument))?
695 };
696 let first = resolved.split('/').next()?.to_string();
697 (!first.is_empty()).then_some(first)
698 }
699
700 fn resolve_constant(argument: &str, name: &str) -> Option<String> {
703 let is_constant_name = !name.is_empty()
704 && name
705 .chars()
706 .all(|character| character.is_ascii_uppercase() || character == '_');
707 if !is_constant_name {
708 return None;
709 }
710 Some(
711 CONSTANTS
712 .iter()
713 .find(|(constant, _)| *constant == name)
714 .unwrap_or_else(|| {
715 panic!(
716 "a workdir write ({argument}) names the constant {name}, which this \
717 test's CONSTANTS table does not pair with a value. Add \
718 (\"{name}\", <path to the constant>) so a rename or a value change is \
719 caught here rather than silently accepted."
720 )
721 })
722 .1
723 .to_string(),
724 )
725 }
726
727 #[test]
734 fn every_workdir_write_is_declared_or_exempted() {
735 let declared: Vec<&str> = DECLARED_WRITES
736 .iter()
737 .map(DeclaredWrite::first_segment)
738 .collect();
739
740 let mut scanned = 0usize;
741 let mut dynamic_rows_used = vec![false; DYNAMIC_EXEMPT.len()];
742 let mut records_dynamic_rows_used = vec![false; RECORDS_DIR_DYNAMIC.len()];
743 for (file, source) in SCANNED {
744 let text = production_text(source);
745 let arguments = workdir_join_arguments(text)
746 .into_iter()
747 .map(|argument| (String::new(), argument))
748 .chain(helper_calls(text, HELPER_WRITES));
749 for (receiver, argument) in arguments {
750 scanned += 1;
751 if OUTSIDE_WORKDIR_ROOTS.contains(&receiver.as_str()) {
752 continue;
753 }
754 let runtime_dir = receiver == RUNTIME_DIR_ROOT;
755 let records_dir = receiver == RECORDS_DIR_ROOT;
756 assert!(
757 records_dir || !names_a_log(&argument),
758 "{file} writes ({argument}) through `{receiver}`, a workdir root. Every log \
759 the runtime writes is a record: it goes through the records directory's root \
760 (`records_root`), which no capsule command can write."
761 );
762 if records_dir {
763 if let Some(row) = RECORDS_DIR_DYNAMIC.iter().position(
764 |(dynamic_file, dynamic_argument, _, _)| {
765 dynamic_file == file && *dynamic_argument == argument
766 },
767 ) {
768 let entry = RECORDS_DIR_DYNAMIC[row].2;
769 assert!(
770 RECORDS_DIR_WRITES.iter().any(|(name, _)| *name == entry),
771 "RECORDS_DIR_DYNAMIC row ({file:?}, {argument:?}) names `{entry}`, \
772 which RECORDS_DIR_WRITES does not declare."
773 );
774 records_dynamic_rows_used[row] = true;
775 continue;
776 }
777 }
778 let Some(entry) = resolve_entry_name(&argument) else {
779 let row =
780 DYNAMIC_EXEMPT
781 .iter()
782 .position(|(exempt_file, exempt_argument, _)| {
783 exempt_file == file && *exempt_argument == argument
784 });
785 assert!(
786 row.is_some(),
787 "{file} writes ({argument}) into a workdir, and the argument is neither \
788 a string literal nor a named constant, so this test cannot say what it \
789 names. Add ({file:?}, {argument:?}, <written reason>) to this test's \
790 DYNAMIC_EXEMPT table."
791 );
792 dynamic_rows_used[row.unwrap()] = true;
793 continue;
794 };
795 if runtime_dir {
796 assert!(
797 RUNTIME_DIR_WRITES.iter().any(|(name, _)| *name == entry),
798 "{file} writes `{entry}` into the runtime directory, and \
799 RUNTIME_DIR_WRITES does not declare it. Add (\"{entry}\", <what it is>)."
800 );
801 continue;
802 }
803 if records_dir {
804 assert!(
805 RECORDS_DIR_WRITES.iter().any(|(name, _)| *name == entry),
806 "{file} writes `{entry}` into the records directory, and \
807 RECORDS_DIR_WRITES does not declare it. Add (\"{entry}\", <what it is>)."
808 );
809 continue;
810 }
811 assert!(
812 !MOVED_OUT_OF_THE_WORKDIR.contains(&entry.as_str()),
813 "{file} writes `{entry}` through `{receiver}` ({argument}), a workdir root. \
814 `{entry}` is a file the runtime owns or a record it keeps: it goes through \
815 `{}`, which no capsule command can write.",
816 root_of(&entry)
817 );
818 assert!(
819 declared.contains(&entry.as_str())
820 || EXEMPT.iter().any(|(name, _)| *name == entry),
821 "{file} writes `{entry}` into a workdir, and no --explain-scope report \
822 declares it. Either add an entry for `{entry}` to DECLARED_WRITES in \
823 workdir_writes.rs, so a consumer diffing the workdir can subtract it, or add \
824 it to this test's EXEMPT table with a written reason — a path the runtime \
825 only reads is the usual one."
826 );
827 }
828 }
829
830 assert!(
832 scanned >= 40,
833 "the workdir write scan found only {scanned} occurrences across {} files, far below \
834 what the runtime has — the scanner is broken, not the runtime",
835 SCANNED.len()
836 );
837
838 for ((file, argument, _, _), used) in
839 RECORDS_DIR_DYNAMIC.iter().zip(records_dynamic_rows_used)
840 {
841 assert!(
842 used,
843 "RECORDS_DIR_DYNAMIC row ({file:?}, {argument:?}) matches no write through \
844 `records_root` in {file}; remove it so the table names only what the runtime does"
845 );
846 }
847
848 for ((file, argument, _), used) in DYNAMIC_EXEMPT.iter().zip(dynamic_rows_used) {
849 assert!(
850 used,
851 "DYNAMIC_EXEMPT row ({file:?}, {argument:?}) matches no write in {file}; remove \
852 it so the table names only what the runtime does"
853 );
854 }
855
856 for (name, _) in EXEMPT {
857 assert!(
858 !declared.contains(name),
859 "`{name}` is listed as exempt and is also declared — remove one"
860 );
861 }
862 }
863
864 #[test]
868 fn no_runtime_owned_file_is_reached_through_a_workdir_root() {
869 let mut checked = 0usize;
870 let mut rows_used = vec![false; MOVED_NAME_WORKDIR_READS.len()];
871 for (file, source) in SCANNED {
872 let text = production_text(source);
873 let methods: Vec<&str> = HELPER_READS.iter().chain(HELPER_WRITES).copied().collect();
874 for (start, receiver, argument) in helper_calls_at(text, &methods) {
875 let Some(entry) = resolve_entry_name(&argument) else {
876 continue;
877 };
878 if !MOVED_OUT_OF_THE_WORKDIR.contains(&entry.as_str()) {
879 continue;
880 }
881 checked += 1;
882 if receiver == root_of(&entry) {
883 continue;
884 }
885 let function = enclosing_fn(text, start);
886 let row = MOVED_NAME_WORKDIR_READS
887 .iter()
888 .position(|(row_file, row_fn, _)| row_file == file && *row_fn == function);
889 let Some(row) = row else {
890 panic!(
891 "{file}: `{function}` reaches `{entry}` through `{receiver}` ({argument}), \
892 a workdir root. `{entry}` is a file the runtime owns or a record it \
893 keeps, and lives outside the workdir: go through `{}`.",
894 root_of(&entry)
895 );
896 };
897 rows_used[row] = true;
898 }
899 }
900 assert!(
901 checked >= 20,
902 "found only {checked} reaches of a runtime-owned file — the scanner is broken"
903 );
904 for ((file, function, _), used) in MOVED_NAME_WORKDIR_READS.iter().zip(rows_used) {
905 assert!(
906 used,
907 "MOVED_NAME_WORKDIR_READS row ({file:?}, {function:?}) matches no read; remove it"
908 );
909 }
910 }
911
912 #[test]
917 fn every_source_file_that_joins_a_workdir_is_scanned() {
918 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
919 let mut stack = vec![root.clone()];
920 let mut checked = 0usize;
921 while let Some(dir) = stack.pop() {
922 let entries = std::fs::read_dir(&dir)
923 .unwrap_or_else(|error| panic!("reading {}: {error}", dir.display()));
924 for entry in entries {
925 let path = entry.unwrap().path();
926 if path.is_dir() {
927 stack.push(path);
928 continue;
929 }
930 if path.extension().is_some_and(|extension| extension == "rs") {
931 checked += 1;
932 if path
935 .file_name()
936 .is_some_and(|name| name == "workdir_writes.rs" || name == "workdir_fs.rs")
937 {
938 continue;
939 }
940 let source = std::fs::read_to_string(&path).unwrap();
941 let text = production_text(&source);
942 if workdir_join_arguments(text).is_empty()
943 && helper_write_arguments(text).is_empty()
944 {
945 continue;
946 }
947 let relative = path
948 .strip_prefix(&root)
949 .unwrap()
950 .to_string_lossy()
951 .replace('\\', "/");
952 assert!(
953 SCANNED.iter().any(|(file, _)| *file == relative),
954 "src/{relative} writes into a workdir but is not in \
955 every_workdir_write_is_declared_or_exempted's SCANNED list, so nothing \
956 checks its entries against the declaration. Add \
957 (\"{relative}\", include_str!(\"{relative}\")) to it."
958 );
959 }
960 }
961 }
962 assert!(
963 checked >= 40,
964 "walked only {checked} source files under src/ — the walk is broken"
965 );
966 }
967
968 const RAW_FS_EXEMPT: &[(&str, &str, &str)] = &[
974 (
975 "session_check.rs",
976 "copy_tree_into",
977 "Reads the files of an eval case's `start` or `check.files`, a directory of the \
978 operator's outside every work folder, walked by `walk_tree` with no link followed: a \
979 link or special file in it refuses the copy. What it reads is written into the work \
980 folder through `WorkdirRoot`.",
981 ),
982 (
983 "network_policy.rs",
984 "resolve_scoped_dir",
985 "Creates a declared `capabilities.filesystem.scope` beneath the root a WASI guest is \
986 preopened at. The preopen itself is opened by path by `wasmtime-wasi`, so a root \
987 here would hold nothing the guest then gets; the scope was validated as relative and \
988 non-escaping at staging.",
989 ),
990 (
991 "resource_plane.rs",
992 "open_no_follow",
993 "The resource plane's own reads of files the capsule exported, under its own \
994 `SymlinkPolicy`; it resolves and confines every path itself and writes nothing.",
995 ),
996 (
997 "instruction_files.rs",
998 "walk_nested",
999 "Lists the accessible work folder at staging, before any capsule command runs, for \
1000 files named like an `inference.instruction_files` entry. It reads directory entries \
1001 only, follows and enters no link, and writes nothing; the listed files are read \
1002 through `resolve_relpath_beneath_root` and `open_no_follow`, never here.",
1003 ),
1004 (
1005 "resource_plane.rs",
1006 "list_export_files",
1007 "The resource plane's listing of a declared export root, on `open_no_follow`'s \
1008 terms.",
1009 ),
1010 (
1011 "retention.rs",
1012 "prune_formations",
1013 "A formation directory under `~/.murmur/formations` and its records subtree under `~/.murmur/records`, outside every workdir.",
1014 ),
1015 (
1016 "retention.rs",
1017 "prune_records",
1018 "A conversation record directory under `~/.murmur/conversations`, outside every workdir.",
1019 ),
1020 (
1021 "retention.rs",
1022 "remove_record",
1023 "A conversation record directory, on `prune_records`'s terms.",
1024 ),
1025 (
1026 "retention.rs",
1027 "list_records",
1028 "The conversation record root, on `prune_records`'s terms.",
1029 ),
1030 (
1031 "retention.rs",
1032 "child_dirs",
1033 "Directories beneath the conversation record root, on `prune_records`'s terms.",
1034 ),
1035 (
1036 "retention.rs",
1037 "locate_message",
1038 "A conversation record file, on `prune_records`'s terms.",
1039 ),
1040 (
1041 "retention.rs",
1042 "find_context_dirs_in",
1043 "Context directories beneath the conversation record root, on `prune_records`'s terms.",
1044 ),
1045 (
1046 "retention.rs",
1047 "read_context_dir",
1048 "A context directory's record file, on `prune_records`'s terms.",
1049 ),
1050 (
1051 "retention.rs",
1052 "count_messages",
1053 "A conversation record file, on `prune_records`'s terms.",
1054 ),
1055 (
1056 "retention.rs",
1057 "stage_truncation",
1058 "A conversation record file being truncated, on `prune_records`'s terms.",
1059 ),
1060 (
1061 "retention.rs",
1062 "sweep_orphan_blobs",
1063 "Image blobs beside a conversation record being truncated, on `prune_records`'s terms.",
1064 ),
1065 (
1066 "retention.rs",
1067 "stage_with_mode",
1068 "`StagedRewrite`, the sibling-file rewrite of a file under `~/.murmur` — a conversation record or a private file — outside every workdir.",
1069 ),
1070 (
1071 "retention.rs",
1072 "commit",
1073 "`StagedRewrite`'s rename into place, on `stage_with_mode`'s terms.",
1074 ),
1075 (
1076 "retention.rs",
1077 "drop",
1078 "`StagedRewrite`'s removal of an uncommitted sibling file, on `stage_with_mode`'s terms.",
1079 ),
1080 (
1081 "runtime.rs",
1082 "check_resume_launchable",
1083 "A conversation record or harness-session file under `~/.murmur/conversations`, \
1084 outside every workdir.",
1085 ),
1086 (
1087 "runtime.rs",
1088 "copy_manifest_into_runtime_dir",
1089 "Reads the project's `murmur.yaml` from the manifest directory; the copy into the \
1090 runtime directory goes through `WorkdirRoot`.",
1091 ),
1092 (
1093 "runtime.rs",
1094 "load_local_skill_files",
1095 "A local skill source named relative to the manifest directory, read before any \
1096 workdir exists.",
1097 ),
1098 (
1099 "runtime.rs",
1100 "read_prompt_file",
1101 "A prompt file named relative to the manifest directory.",
1102 ),
1103 (
1104 "runtime.rs",
1105 "stage_session",
1106 "The operator's `--workdir` itself, checked by path before it is opened as the \
1107 accessible root, and `<manifest dir>/workdir/<session id>`, created before any root \
1108 exists. Nothing beneath either is touched by path.",
1109 ),
1110 (
1111 "sandbox.rs",
1112 "test_runtime_dir",
1113 "Test-only: creates a runtime directory's three subdirectories under a temporary \
1114 directory the test owns.",
1115 ),
1116 (
1117 "sandbox.rs",
1118 "build_sealed_root",
1119 "Host directories offered as the composed root's base, outside every workdir.",
1120 ),
1121 (
1122 "sandbox.rs",
1123 "open_o_path",
1124 "An `O_PATH` handle for a Landlock rule, opened from a host path the grant names; it \
1125 reads and writes nothing.",
1126 ),
1127 (
1128 "sandbox.rs",
1129 "open_state_dir",
1130 "An `O_PATH` handle for a native tool's state store, `~/.murmur/state/<store>/`, \
1131 created by the runtime outside every workdir and opened with no link followed at \
1132 its own name; it reads and writes nothing.",
1133 ),
1134 (
1135 "sandbox.rs",
1136 "open_host_path",
1137 "An `O_PATH` handle on a host path from `~/.murmur/config.yaml`, a directory launch \
1138 refuses when it is or lies under the work folder, opened with no link followed at \
1139 its resolved name; it reads and writes nothing.",
1140 ),
1141 (
1142 "sandbox.rs",
1143 "open_host",
1144 "The ELF images of host binaries and libraries a shell grant or a derived interpreter \
1145 runtime names, opened to read their headers and dependencies.",
1146 ),
1147 (
1148 "sandbox.rs",
1149 "resolve_soname",
1150 "Host library search directories, outside every workdir.",
1151 ),
1152 (
1153 "sealed.rs",
1154 "inspect_installed_profile",
1155 "The host's installed AppArmor profile under `/etc/apparmor.d`.",
1156 ),
1157 (
1158 "sealed.rs",
1159 "inspect_profile_attachment",
1160 "`/proc/self/attr/current`, the runtime's own AppArmor attachment.",
1161 ),
1162 (
1163 "sealed.rs",
1164 "read_trimmed",
1165 "Host settings under `/sys` and `/proc`.",
1166 ),
1167 ];
1168
1169 const RAW_FS_FUNCTIONS: &[&str] = &[
1172 "write(",
1173 "copy(",
1174 "rename(",
1175 "create_dir_all(",
1176 "create_dir(",
1177 "set_permissions(",
1178 "remove_file(",
1179 "remove_dir_all(",
1180 "remove_dir(",
1181 "hard_link(",
1182 "symlink(",
1183 "read_to_string(",
1184 "read(",
1185 ];
1186
1187 const RAW_FS_CALLS: &[&str] = &[
1189 "File::create(",
1190 "File::options(",
1191 "OpenOptions::new(",
1192 "File::open(",
1193 "try_exists(",
1194 ];
1195
1196 const RAW_FS_PREDICATES: &[&str] = &[".exists()", ".is_file()", ".is_dir()"];
1199
1200 fn enclosing_fn(source: &str, at: usize) -> &str {
1202 let before = &source[..at];
1203 let mut cursor = before.len();
1204 while let Some(index) = before[..cursor].rfind("fn ") {
1205 cursor = index;
1206 let boundary = index == 0
1207 || !before.as_bytes()[index - 1].is_ascii_alphanumeric()
1208 && before.as_bytes()[index - 1] != b'_';
1209 let name: String = before[index + 3..]
1210 .chars()
1211 .take_while(|character| character.is_ascii_alphanumeric() || *character == '_')
1212 .collect();
1213 if boundary && !name.is_empty() {
1214 return &before[index + 3..index + 3 + name.len()];
1215 }
1216 }
1217 "<file level>"
1218 }
1219
1220 fn raw_fs_calls(source: &str) -> Vec<(String, String)> {
1222 let mut found = Vec::new();
1223 for function in RAW_FS_FUNCTIONS {
1224 let pattern = format!("fs::{function}");
1225 for (at, _) in source.match_indices(&pattern) {
1226 found.push((enclosing_fn(source, at).to_string(), pattern.clone()));
1227 }
1228 }
1229 for call in RAW_FS_CALLS {
1230 for (at, _) in source.match_indices(call) {
1231 found.push((enclosing_fn(source, at).to_string(), call.to_string()));
1232 }
1233 }
1234 for predicate in RAW_FS_PREDICATES {
1235 for (at, _) in source.match_indices(predicate) {
1236 let receiver = source[..at]
1237 .trim_end()
1238 .rsplit(|character: char| {
1239 !(character.is_ascii_alphanumeric() || "_().".contains(character))
1240 })
1241 .next()
1242 .unwrap_or_default();
1243 let last = receiver
1244 .trim_end_matches("()")
1245 .rsplit('.')
1246 .next()
1247 .unwrap_or_default();
1248 if matches!(last, "meta" | "metadata" | "file_type") {
1249 continue;
1250 }
1251 found.push((enclosing_fn(source, at).to_string(), predicate.to_string()));
1252 }
1253 }
1254 found
1255 }
1256
1257 #[test]
1265 fn no_workdir_file_bypasses_the_no_follow_helper() {
1266 let mut rows_used = vec![false; RAW_FS_EXEMPT.len()];
1267 for (file, source) in SCANNED {
1268 for (function, call) in raw_fs_calls(production_text(source)) {
1269 let row = RAW_FS_EXEMPT
1270 .iter()
1271 .position(|(exempt_file, exempt_fn, _)| {
1272 exempt_file == file && *exempt_fn == function
1273 });
1274 let Some(row) = row else {
1275 panic!(
1276 "{file}: `{function}` calls `{call}` directly. A path beneath the \
1277 accessible workdir or the session directory goes through WorkdirRoot \
1278 (crate::workdir_fs), which follows no link; a path outside every \
1279 workdir gets a ({file:?}, {function:?}, <why it is not beneath a \
1280 workdir>) row in RAW_FS_EXEMPT."
1281 );
1282 };
1283 rows_used[row] = true;
1284 }
1285 }
1286 for ((file, function, _), used) in RAW_FS_EXEMPT.iter().zip(rows_used) {
1287 assert!(
1288 used,
1289 "RAW_FS_EXEMPT row ({file:?}, {function:?}) matches no raw filesystem call — \
1290 the call is gone or moved; remove or re-key the row"
1291 );
1292 }
1293 }
1294
1295 #[test]
1296 fn the_raw_call_scanner_names_the_function_and_the_call() {
1297 let source = "fn staged() {\n let _ = std::fs::write(self.workdir.join(\"plans\").join(\"x\"), b\"\");\n}\nfn helper(root: &WorkdirRoot) {\n root.write_file(\"plans/x\", b\"\", 0o644);\n if metadata.is_file() {}\n}\n";
1298 assert_eq!(
1299 raw_fs_calls(source),
1300 vec![("staged".to_string(), "fs::write(".to_string())]
1301 );
1302 assert_eq!(
1303 helper_write_arguments(source),
1304 vec!["\"plans/x\"".to_string()]
1305 );
1306 assert_eq!(resolve_entry_name("\"plans/x\"").as_deref(), Some("plans"));
1307 assert_eq!(
1308 resolve_entry_name("format!(\"{BLOB_DIR_NAME}/{sha256}\")").as_deref(),
1309 Some("blobs")
1310 );
1311 assert_eq!(
1312 resolve_entry_name("format!(\"tools/{name}/x\")").as_deref(),
1313 Some("tools")
1314 );
1315 assert_eq!(
1316 resolve_entry_name("CHILDREN_DIR").as_deref(),
1317 Some("children")
1318 );
1319 assert_eq!(resolve_entry_name("&rel"), None);
1320 assert_eq!(resolve_entry_name("format!(\"{}/x\", a)"), None);
1321 }
1322
1323 #[test]
1326 fn runtime_writes_lists_none_of_the_runtime_directory_files() {
1327 for write in runtime_writes() {
1328 let last = write.path.rsplit('/').next().unwrap_or_default();
1329 assert!(
1330 !MOVED_OUT_OF_THE_WORKDIR.contains(&last),
1331 "{} is declared as a workdir write",
1332 write.path
1333 );
1334 }
1335 }
1336
1337 #[test]
1339 fn no_declared_path_is_a_shell_log() {
1340 for write in runtime_writes() {
1341 assert!(
1342 write.path != "logs" && !write.path.starts_with("logs/"),
1343 "{} is declared as a workdir write",
1344 write.path
1345 );
1346 }
1347 }
1348
1349 #[test]
1354 fn runtime_writes_are_exactly_peer_in_and_a_script_capsules_out() {
1355 assert_eq!(
1356 runtime_writes(),
1357 vec![
1358 RuntimeWriteReport {
1359 path: "peer-in".to_string(),
1360 kind: RuntimeWriteKind::Directory,
1361 condition: RuntimeWriteCondition::PeerFetch,
1362 },
1363 RuntimeWriteReport {
1364 path: "out".to_string(),
1365 kind: RuntimeWriteKind::Directory,
1366 condition: RuntimeWriteCondition::ScriptSession,
1367 },
1368 ]
1369 );
1370 for write in runtime_writes() {
1371 for old in [".murmur", ".capsule-home", "children", "home"] {
1372 assert!(!write.path.contains(old), "{}", write.path);
1373 }
1374 }
1375 }
1376
1377 #[test]
1380 fn no_declared_path_is_a_temp_store() {
1381 for write in runtime_writes() {
1382 let last = write.path.rsplit('/').next().unwrap_or_default();
1383 assert!(
1384 !last.contains("tmp"),
1385 "{} is declared as a workdir write",
1386 write.path
1387 );
1388 }
1389 }
1390
1391 #[test]
1398 fn a_serialized_row_carries_exactly_path_kind_and_condition() {
1399 for write in runtime_writes() {
1400 let line = serde_json::to_string(&write).unwrap();
1401 assert_eq!(
1402 line,
1403 format!(
1404 r#"{{"path":"{}","kind":"{}","condition":"{}"}}"#,
1405 write.path,
1406 write.kind.wire_name(),
1407 write.condition.wire_name()
1408 )
1409 );
1410 }
1411 }
1412
1413 #[test]
1416 fn no_declared_path_is_reported_twice() {
1417 let writes = runtime_writes();
1418 let mut paths: Vec<&str> = writes.iter().map(|write| write.path.as_str()).collect();
1419 paths.sort_unstable();
1420 let count = paths.len();
1421 paths.dedup();
1422 assert_eq!(paths.len(), count, "a path is declared twice");
1423 }
1424
1425 #[test]
1426 fn the_rendering_names_every_declared_path_relative_to_the_work_folder() {
1427 let writes = runtime_writes();
1428 let rendered = render_runtime_writes(&writes);
1429 assert!(rendered.contains("paths are relative to the work folder"));
1430 for write in &writes {
1431 assert!(
1432 rendered.contains(&write.path),
1433 "{} is missing from the rendering",
1434 write.path
1435 );
1436 }
1437 assert!(rendered.ends_with('\n'));
1438 }
1439}