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