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