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