1use serde::Serialize;
58
59use crate::sandbox::EnforcementTier;
60
61pub const SESSION_ID_PLACEHOLDER: &str = "<session-id>";
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum RuntimeWriteKind {
74 File,
75 Directory,
76}
77
78impl RuntimeWriteKind {
79 #[must_use]
81 pub fn wire_name(self) -> &'static str {
82 match self {
83 Self::File => "file",
84 Self::Directory => "directory",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum RuntimeWriteScope {
98 Accessible,
100 Session,
102}
103
104impl RuntimeWriteScope {
105 #[must_use]
107 pub fn wire_name(self) -> &'static str {
108 match self {
109 Self::Accessible => "accessible",
110 Self::Session => "session",
111 }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "kebab-case")]
121pub enum RuntimeWriteCondition {
122 Always,
124 AgentSession,
126 ScriptSession,
128 Shell,
130 PeerFetch,
132 Spawn,
134 Sealed,
136}
137
138impl RuntimeWriteCondition {
139 #[must_use]
141 pub fn wire_name(self) -> &'static str {
142 match self {
143 Self::Always => "always",
144 Self::AgentSession => "agent-session",
145 Self::ScriptSession => "script-session",
146 Self::Shell => "shell",
147 Self::PeerFetch => "peer-fetch",
148 Self::Spawn => "spawn",
149 Self::Sealed => "sealed",
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158pub struct RuntimeWriteReport {
159 pub path: String,
161 pub kind: RuntimeWriteKind,
162 pub scope: RuntimeWriteScope,
163 pub condition: RuntimeWriteCondition,
164}
165
166struct DeclaredWrite {
172 entry: &'static str,
173 kind: RuntimeWriteKind,
174 scope: RuntimeWriteScope,
175 condition: RuntimeWriteCondition,
176}
177
178impl DeclaredWrite {
179 fn report(&self) -> RuntimeWriteReport {
180 let path = match self.scope {
181 RuntimeWriteScope::Accessible => self.entry.to_string(),
182 RuntimeWriteScope::Session => {
183 format!(".murmur/{SESSION_ID_PLACEHOLDER}/{}", self.entry)
184 }
185 };
186 RuntimeWriteReport {
187 path,
188 kind: self.kind,
189 scope: self.scope,
190 condition: self.condition,
191 }
192 }
193
194 #[cfg(test)]
197 fn first_segment(&self) -> &'static str {
198 self.entry.split('/').next().unwrap_or(self.entry)
199 }
200}
201
202const DECLARED_WRITES: &[DeclaredWrite] = &[
208 DeclaredWrite {
210 entry: ".murmur/<session-id>",
212 kind: RuntimeWriteKind::Directory,
213 scope: RuntimeWriteScope::Accessible,
214 condition: RuntimeWriteCondition::Always,
215 },
216 DeclaredWrite {
217 entry: "logs",
221 kind: RuntimeWriteKind::Directory,
222 scope: RuntimeWriteScope::Accessible,
223 condition: RuntimeWriteCondition::Shell,
224 },
225 DeclaredWrite {
226 entry: "peer-in",
228 kind: RuntimeWriteKind::Directory,
229 scope: RuntimeWriteScope::Accessible,
230 condition: RuntimeWriteCondition::PeerFetch,
231 },
232 DeclaredWrite {
233 entry: ".murmur/children",
237 kind: RuntimeWriteKind::Directory,
238 scope: RuntimeWriteScope::Accessible,
239 condition: RuntimeWriteCondition::Spawn,
240 },
241 DeclaredWrite {
242 entry: "out",
245 kind: RuntimeWriteKind::Directory,
246 scope: RuntimeWriteScope::Accessible,
247 condition: RuntimeWriteCondition::ScriptSession,
248 },
249 DeclaredWrite {
251 entry: "out",
253 kind: RuntimeWriteKind::Directory,
254 scope: RuntimeWriteScope::Session,
255 condition: RuntimeWriteCondition::AgentSession,
256 },
257 DeclaredWrite {
258 entry: ".capsule-home",
261 kind: RuntimeWriteKind::Directory,
262 scope: RuntimeWriteScope::Session,
263 condition: RuntimeWriteCondition::Shell,
264 },
265 DeclaredWrite {
266 entry: ".mur-tmp",
269 kind: RuntimeWriteKind::Directory,
270 scope: RuntimeWriteScope::Session,
271 condition: RuntimeWriteCondition::Sealed,
272 },
273];
274
275pub(crate) fn runtime_writes(tier: EnforcementTier) -> Vec<RuntimeWriteReport> {
286 let sealed = tier == EnforcementTier::KernelSealed;
287 DECLARED_WRITES
288 .iter()
289 .filter(|declared| sealed || declared.condition != RuntimeWriteCondition::Sealed)
290 .map(DeclaredWrite::report)
291 .collect()
292}
293
294#[must_use]
300pub fn render_runtime_writes(writes: &[RuntimeWriteReport]) -> String {
301 let mut out = String::new();
302 out.push_str(
303 " paths are relative to the accessible workdir; `<session-id>` is a literal segment, \
304 replaced by the run's own session id\n",
305 );
306 if writes.is_empty() {
307 out.push_str(" runtime writes: <none>\n");
308 return out;
309 }
310 out.push_str(" runtime writes:\n");
311 for write in writes {
312 out.push_str(&format!(
313 " - {} ({}, {}, {})\n",
314 write.path,
315 write.kind.wire_name(),
316 write.scope.wire_name(),
317 write.condition.wire_name()
318 ));
319 }
320 out
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 const SCANNED: &[(&str, &str)] = &[
337 ("agent.rs", include_str!("agent.rs")),
338 ("agent/inventory.rs", include_str!("agent/inventory.rs")),
339 ("agent/process.rs", include_str!("agent/process.rs")),
340 ("child_launch.rs", include_str!("child_launch.rs")),
341 ("delegation.rs", include_str!("delegation.rs")),
342 ("delegation_plane.rs", include_str!("delegation_plane.rs")),
343 ("hooks.rs", include_str!("hooks.rs")),
344 ("murmur_md.rs", include_str!("murmur_md.rs")),
345 ("otel.rs", include_str!("otel.rs")),
346 ("network_policy.rs", include_str!("network_policy.rs")),
347 ("plan.rs", include_str!("plan.rs")),
348 ("protected_paths.rs", include_str!("protected_paths.rs")),
349 ("recipes.rs", include_str!("recipes.rs")),
350 ("records_dir.rs", include_str!("records_dir.rs")),
351 ("resource_plane.rs", include_str!("resource_plane.rs")),
352 ("retention.rs", include_str!("retention.rs")),
353 ("runtime.rs", include_str!("runtime.rs")),
354 (
355 "runtime/artifact_change.rs",
356 include_str!("runtime/artifact_change.rs"),
357 ),
358 ("runtime_dir.rs", include_str!("runtime_dir.rs")),
359 ("sandbox.rs", include_str!("sandbox.rs")),
360 ("sealed.rs", include_str!("sealed.rs")),
361 ("shell.rs", include_str!("shell.rs")),
362 ("tool_annotations.rs", include_str!("tool_annotations.rs")),
363 ("trace.rs", include_str!("trace.rs")),
364 ("trace_blobs.rs", include_str!("trace_blobs.rs")),
365 ("types.rs", include_str!("types.rs")),
366 ];
367
368 const CONSTANTS: &[(&str, &str)] = &[
374 ("MANIFEST_FILENAME", murmur_artifact::MANIFEST_FILENAME),
375 ("COMPLETION_FILE", crate::delegation::COMPLETION_FILE),
376 ("BLOB_DIR_NAME", crate::trace_blobs::BLOB_DIR_NAME),
377 ("SEALED_TMP_DIR_NAME", crate::sealed::SEALED_TMP_DIR_NAME),
378 (
379 "SYNTHETIC_HOME_DIR_NAME",
380 crate::shell::SYNTHETIC_HOME_DIR_NAME,
381 ),
382 ("TASK_INPUT_FILE", crate::runtime::TASK_INPUT_FILE),
383 (
384 "LEGACY_TASK_INPUT_FILE",
385 crate::runtime::LEGACY_TASK_INPUT_FILE,
386 ),
387 ("TRACE_FILE_NAME", crate::trace::TRACE_FILE_NAME),
388 ("CHILDREN_DIR", crate::child_launch::CHILDREN_DIR),
389 ("HOOKS_SUBDIR", crate::records_dir::HOOKS_SUBDIR),
390 ];
391
392 const EXEMPT: &[(&str, &str)] = &[
394 (
395 "input.txt",
396 "Read, never written: the operator's task file `import_task_input` reads once when \
397 the workdir has no `task.md`. Source text cannot tell a read from a write, so a \
398 path the runtime only ever opens for reading is exempted here rather than declared \
399 as something the runtime produces.",
400 ),
401 (
402 "package.json",
403 "Read, never written: the recipe detector opens it to learn which scripts a workdir \
404 already declares. Exempt on `input.txt`'s terms.",
405 ),
406 ];
407
408 const DYNAMIC_EXEMPT: &[(&str, &str, &str)] = &[
411 (
412 "protected_paths.rs",
413 "&rule.relative",
414 "A `capabilities.filesystem.read_only` path from the manifest, resolved to decide \
415 whether a call is refused. The runtime reads it and writes nothing.",
416 ),
417 (
418 "protected_paths.rs",
419 "&lexical",
420 "The lexically normalised form of a path a tool call named, resolved for the same \
421 refusal decision.",
422 ),
423 (
424 "resource_plane.rs",
425 "declared_root",
426 "The `exports.files` / `exports.peer_files` root a manifest declares. The plane \
427 serves what the capsule put there; it creates nothing of its own.",
428 ),
429 (
430 "resource_plane.rs",
431 "&export.root",
432 "The same declared root, resolved when a request is served. Exempt on \
433 `declared_root`'s terms.",
434 ),
435 (
436 "runtime.rs",
437 "&stored_path",
438 "Where a redeemed peer handle's bytes go, composed by `peer_handoff::stored_path_for` \
439 under `peer-in/`. Declared through that directory's own entry, which is the name a \
440 consumer excludes.",
441 ),
442 (
443 "runtime.rs",
444 "rel",
445 "`write_staged_file`'s path, `tools/<name>/<file>` in the runtime directory, composed \
446 by each of its callers. Declared through RUNTIME_DIR_WRITES' `tools` entry.",
447 ),
448 (
449 "types.rs",
450 "rel",
451 "`StagedSession::write_workdir_file`'s path, named by the embedding caller for an \
452 input it stages for the capsule before launch. A launch writes nothing there of its \
453 own.",
454 ),
455 (
456 "shell.rs",
457 "&relative_path",
458 "A command's full-output log, `logs/<stem>.log`, composed on the line above. Declared \
459 through the `logs` entry.",
460 ),
461 (
462 "child_launch.rs",
463 "rel",
464 "One child's directory, `.murmur/children/<name>-<16 hex>`, composed by \
465 `child_workdir_rel`. Declared through the `.murmur/children` entry.",
466 ),
467 (
468 "child_launch.rs",
469 "child_workdir_rel(capsule_name)",
470 "The host path of the same child directory, for the child's `--workdir` argument. \
471 Declared through the `.murmur/children` entry.",
472 ),
473 (
474 "trace.rs",
475 "trace",
476 "A resumed-from session's `trace.jsonl`, relative to the records group every session \
477 of this workdir root keeps its records directory in, outside every workdir.",
478 ),
479 (
480 "runtime.rs",
481 "&name",
482 "The launched manifest's own file name, copied into the runtime directory through \
483 `runtime_root`. Declared through RUNTIME_DIR_WRITES' `murmur.yaml` entry, the \
484 project manifest's name.",
485 ),
486 (
487 "records_dir.rs",
488 "&rel",
489 "`<workdir root mirrored>/<session-id>` beneath `~/.murmur/records`, created at staging \
490 outside every workdir.",
491 ),
492 ];
493
494 fn production_text(source: &str) -> &str {
500 match source.find("#[cfg(test)]\nmod tests {") {
501 Some(index) => &source[..index],
502 None => source,
503 }
504 }
505
506 fn parenthesised(source: &str, open: usize) -> &str {
508 let bytes = source.as_bytes();
509 let mut depth = 1usize;
510 let mut index = open;
511 while index < bytes.len() && depth > 0 {
512 match bytes[index] {
513 b'(' => depth += 1,
514 b')' => depth -= 1,
515 _ => {}
516 }
517 index += 1;
518 }
519 assert!(depth == 0, "unbalanced parentheses in a scanned call");
520 &source[open..index - 1]
521 }
522
523 fn one_line(text: &str) -> String {
526 text.split_whitespace().collect::<Vec<_>>().join(" ")
527 }
528
529 fn first_argument(arguments: &str) -> &str {
531 let mut depth = 0i32;
532 let mut in_string = false;
533 for (index, character) in arguments.char_indices() {
534 match character {
535 '"' => in_string = !in_string,
536 '(' | '[' | '{' if !in_string => depth += 1,
537 ')' | ']' | '}' if !in_string => depth -= 1,
538 ',' if !in_string && depth == 0 => return &arguments[..index],
539 _ => {}
540 }
541 }
542 arguments
543 }
544
545 fn workdir_join_arguments(source: &str) -> Vec<String> {
551 let mut found = Vec::new();
552 let mut cursor = 0;
553 while let Some(offset) = source[cursor..].find(".join(") {
554 let start = cursor + offset;
555 cursor = start + ".join(".len();
556 if !source[..start].trim_end().ends_with("workdir") {
557 continue;
558 }
559 found.push(one_line(parenthesised(source, cursor)));
560 }
561 found
562 }
563
564 const HELPER_WRITES: &[&str] = &[
566 ".write_file(",
567 ".write_file_exact_mode(",
568 ".overwrite_in_place(",
569 ".open_append(",
570 ".create_dir_all(",
571 ];
572
573 fn helper_write_arguments(source: &str) -> Vec<String> {
578 helper_calls(source, HELPER_WRITES)
579 .into_iter()
580 .map(|(_, argument)| argument)
581 .collect()
582 }
583
584 fn helper_calls(source: &str, methods: &[&str]) -> Vec<(String, String)> {
589 helper_calls_at(source, methods)
590 .into_iter()
591 .map(|(_, receiver, argument)| (receiver, argument))
592 .collect()
593 }
594
595 fn helper_calls_at(source: &str, methods: &[&str]) -> Vec<(usize, String, String)> {
597 let mut found = Vec::new();
598 for method in methods {
599 let mut cursor = 0;
600 while let Some(offset) = source[cursor..].find(method) {
601 let start = cursor + offset;
602 cursor = start + method.len();
603 if source[..start].ends_with("fs") {
604 continue;
605 }
606 let receiver: String = source[..start]
607 .trim_end()
608 .chars()
609 .rev()
610 .take_while(|character| character.is_ascii_alphanumeric() || *character == '_')
611 .collect::<Vec<_>>()
612 .into_iter()
613 .rev()
614 .collect();
615 found.push((
616 start,
617 receiver,
618 one_line(first_argument(parenthesised(source, cursor))),
619 ));
620 }
621 }
622 found
623 }
624
625 const RUNTIME_DIR_ROOT: &str = "runtime_root";
628
629 const RECORDS_DIR_ROOT: &str = "records_root";
632
633 const RECORDS_DIR_WRITES: &[(&str, &str)] = &[
635 (
636 "trace.jsonl",
637 "The session's trace: every appender, and a resumed session's `shell_lost` markers.",
638 ),
639 (
640 "blobs",
641 "Trace bodies too large to inline, by their sha256.",
642 ),
643 (
644 "logs",
645 "`bootstrap.log`, `otel.log` and one `hook-<name>.log` per failing hook.",
646 ),
647 (
648 "completion.json",
649 "A delegated child's record of how it ended, written by the child and by its \
650 parent's watcher.",
651 ),
652 (
653 "hooks",
654 "The `\".\"` of every hook declared `capabilities.filesystem.records: true`.",
655 ),
656 ];
657
658 const MOVED_LOG_PREFIXES: &[&str] = &["logs/bootstrap.log", "logs/otel.log", "logs/hook-"];
661
662 fn literal_prefix(argument: &str) -> Option<&str> {
665 let argument = argument.trim_start_matches('&');
666 let argument = argument
667 .strip_prefix("format!(")
668 .map(str::trim_start)
669 .unwrap_or(argument);
670 let (literal, _) = argument.strip_prefix('"')?.split_once('"')?;
671 Some(literal.split('{').next().unwrap_or(literal))
672 }
673
674 fn names_a_moved_log(argument: &str) -> bool {
676 literal_prefix(argument).is_some_and(|literal| {
677 MOVED_LOG_PREFIXES
678 .iter()
679 .any(|prefix| literal.starts_with(prefix))
680 })
681 }
682
683 const OUTSIDE_WORKDIR_ROOTS: &[&str] = &["sealed_etc_root", "harness_root", "session_dir_root"];
687
688 const RUNTIME_DIR_WRITES: &[(&str, &str)] = &[
691 (
692 "tools",
693 "Every staged and pulled artifact's manifest, `skill.md` and native entrypoint, and \
694 every runtime-provided tool's manifest.",
695 ),
696 (
697 "MURMUR.md",
698 "The environment reference, written at staging and after port binding.",
699 ),
700 (
701 "task.md",
702 "The task: `mur run --task`, the import from the workdir, every A2A message and \
703 every reopen's rewrite.",
704 ),
705 (
706 "plans",
707 "One `plan-<n>.json` per submitted plan, read back when it runs.",
708 ),
709 (
710 "murmur.yaml",
711 "The project manifest, copied on every launch.",
712 ),
713 ];
714
715 const MOVED_OUT_OF_THE_WORKDIR: &[&str] = &[
718 "tools",
719 "MURMUR.md",
720 "task.md",
721 "plans",
722 "murmur.yaml",
723 ".mur-etc",
724 ".process-driver",
725 "trace.jsonl",
726 "blobs",
727 "completion.json",
728 "eval.jsonl",
729 ];
730
731 fn root_of(entry: &str) -> &'static str {
734 if RECORDS_DIR_WRITES.iter().any(|(name, _)| *name == entry) || entry == "eval.jsonl" {
735 RECORDS_DIR_ROOT
736 } else {
737 RUNTIME_DIR_ROOT
738 }
739 }
740
741 const MOVED_NAME_WORKDIR_READS: &[(&str, &str, &str)] = &[(
743 "runtime.rs",
744 "import_task_input",
745 "The one read of the operator's `task.md` from the workdir, at session start, copied \
746 into the runtime directory and never read again.",
747 )];
748
749 const HELPER_READS: &[&str] = &[
751 ".read_file(",
752 ".read_to_string(",
753 ".list_dir(",
754 ".file_len(",
755 ".entry_kind(",
756 ".remove_file(",
757 ".remove_dir_all(",
758 ".open_beneath(",
759 ];
760
761 fn resolve_entry_name(argument: &str) -> Option<String> {
768 let argument = argument.trim_start_matches('&');
769 let argument = argument
770 .strip_prefix("format!(")
771 .map(str::trim_start)
772 .unwrap_or(argument);
773 let resolved = if let Some(rest) = argument.strip_prefix('"') {
774 let (literal, _) = rest.split_once('"')?;
775 match literal.strip_prefix('{') {
776 Some(inner) => resolve_constant(argument, inner.split_once('}')?.0)?,
777 None if literal.starts_with('{') => return None,
778 None => literal.split('{').next()?.to_string(),
779 }
780 } else {
781 resolve_constant(argument, argument.rsplit("::").next().unwrap_or(argument))?
782 };
783 let first = resolved.split('/').next()?.to_string();
784 (!first.is_empty()).then_some(first)
785 }
786
787 fn resolve_constant(argument: &str, name: &str) -> Option<String> {
790 let is_constant_name = !name.is_empty()
791 && name
792 .chars()
793 .all(|character| character.is_ascii_uppercase() || character == '_');
794 if !is_constant_name {
795 return None;
796 }
797 Some(
798 CONSTANTS
799 .iter()
800 .find(|(constant, _)| *constant == name)
801 .unwrap_or_else(|| {
802 panic!(
803 "a workdir write ({argument}) names the constant {name}, which this \
804 test's CONSTANTS table does not pair with a value. Add \
805 (\"{name}\", <path to the constant>) so a rename or a value change is \
806 caught here rather than silently accepted."
807 )
808 })
809 .1
810 .to_string(),
811 )
812 }
813
814 #[test]
821 fn every_workdir_write_is_declared_or_exempted() {
822 let declared: Vec<&str> = DECLARED_WRITES
823 .iter()
824 .map(DeclaredWrite::first_segment)
825 .collect();
826
827 let mut scanned = 0usize;
828 let mut dynamic_rows_used = vec![false; DYNAMIC_EXEMPT.len()];
829 for (file, source) in SCANNED {
830 let text = production_text(source);
831 let arguments = workdir_join_arguments(text)
832 .into_iter()
833 .map(|argument| (String::new(), argument))
834 .chain(helper_calls(text, HELPER_WRITES));
835 for (receiver, argument) in arguments {
836 scanned += 1;
837 if OUTSIDE_WORKDIR_ROOTS.contains(&receiver.as_str()) {
838 continue;
839 }
840 let runtime_dir = receiver == RUNTIME_DIR_ROOT;
841 let records_dir = receiver == RECORDS_DIR_ROOT;
842 assert!(
843 records_dir || !names_a_moved_log(&argument),
844 "{file} writes ({argument}) through `{receiver}`, a workdir root. The \
845 session's own logs are records: they go through the records directory's root \
846 (`records_root`), which no capsule command can write."
847 );
848 let Some(entry) = resolve_entry_name(&argument) else {
849 let row =
850 DYNAMIC_EXEMPT
851 .iter()
852 .position(|(exempt_file, exempt_argument, _)| {
853 exempt_file == file && *exempt_argument == argument
854 });
855 assert!(
856 row.is_some(),
857 "{file} writes ({argument}) into a workdir, and the argument is neither \
858 a string literal nor a named constant, so this test cannot say what it \
859 names. Add ({file:?}, {argument:?}, <written reason>) to this test's \
860 DYNAMIC_EXEMPT table."
861 );
862 dynamic_rows_used[row.unwrap()] = true;
863 continue;
864 };
865 if runtime_dir {
866 assert!(
867 RUNTIME_DIR_WRITES.iter().any(|(name, _)| *name == entry),
868 "{file} writes `{entry}` into the runtime directory, and \
869 RUNTIME_DIR_WRITES does not declare it. Add (\"{entry}\", <what it is>)."
870 );
871 continue;
872 }
873 if records_dir {
874 assert!(
875 RECORDS_DIR_WRITES.iter().any(|(name, _)| *name == entry),
876 "{file} writes `{entry}` into the records directory, and \
877 RECORDS_DIR_WRITES does not declare it. Add (\"{entry}\", <what it is>)."
878 );
879 continue;
880 }
881 assert!(
882 !MOVED_OUT_OF_THE_WORKDIR.contains(&entry.as_str()),
883 "{file} writes `{entry}` through `{receiver}` ({argument}), a workdir root. \
884 `{entry}` is a file the runtime owns or a record it keeps: it goes through \
885 `{}`, which no capsule command can write.",
886 root_of(&entry)
887 );
888 assert!(
889 declared.contains(&entry.as_str())
890 || EXEMPT.iter().any(|(name, _)| *name == entry),
891 "{file} writes `{entry}` into a workdir, and no --explain-scope report \
892 declares it. Either add an entry for `{entry}` to DECLARED_WRITES in \
893 workdir_writes.rs, so a consumer diffing the workdir can subtract it, or add \
894 it to this test's EXEMPT table with a written reason — a path the runtime \
895 only reads is the usual one."
896 );
897 }
898 }
899
900 assert!(
902 scanned >= 40,
903 "the workdir write scan found only {scanned} occurrences across {} files, far below \
904 what the runtime has — the scanner is broken, not the runtime",
905 SCANNED.len()
906 );
907
908 for ((file, argument, _), used) in DYNAMIC_EXEMPT.iter().zip(dynamic_rows_used) {
909 assert!(
910 used,
911 "DYNAMIC_EXEMPT row ({file:?}, {argument:?}) matches no write in {file}; remove \
912 it so the table names only what the runtime does"
913 );
914 }
915
916 for (name, _) in EXEMPT {
917 assert!(
918 !declared.contains(name),
919 "`{name}` is listed as exempt and is also declared — remove one"
920 );
921 }
922 }
923
924 #[test]
928 fn no_runtime_owned_file_is_reached_through_a_workdir_root() {
929 let mut checked = 0usize;
930 let mut rows_used = vec![false; MOVED_NAME_WORKDIR_READS.len()];
931 for (file, source) in SCANNED {
932 let text = production_text(source);
933 let methods: Vec<&str> = HELPER_READS.iter().chain(HELPER_WRITES).copied().collect();
934 for (start, receiver, argument) in helper_calls_at(text, &methods) {
935 let Some(entry) = resolve_entry_name(&argument) else {
936 continue;
937 };
938 if !MOVED_OUT_OF_THE_WORKDIR.contains(&entry.as_str()) {
939 continue;
940 }
941 checked += 1;
942 if receiver == root_of(&entry) {
943 continue;
944 }
945 let function = enclosing_fn(text, start);
946 let row = MOVED_NAME_WORKDIR_READS
947 .iter()
948 .position(|(row_file, row_fn, _)| row_file == file && *row_fn == function);
949 let Some(row) = row else {
950 panic!(
951 "{file}: `{function}` reaches `{entry}` through `{receiver}` ({argument}), \
952 a workdir root. `{entry}` is a file the runtime owns or a record it \
953 keeps, and lives outside the workdir: go through `{}`.",
954 root_of(&entry)
955 );
956 };
957 rows_used[row] = true;
958 }
959 }
960 assert!(
961 checked >= 20,
962 "found only {checked} reaches of a runtime-owned file — the scanner is broken"
963 );
964 for ((file, function, _), used) in MOVED_NAME_WORKDIR_READS.iter().zip(rows_used) {
965 assert!(
966 used,
967 "MOVED_NAME_WORKDIR_READS row ({file:?}, {function:?}) matches no read; remove it"
968 );
969 }
970 }
971
972 #[test]
977 fn every_source_file_that_joins_a_workdir_is_scanned() {
978 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
979 let mut stack = vec![root.clone()];
980 let mut checked = 0usize;
981 while let Some(dir) = stack.pop() {
982 let entries = std::fs::read_dir(&dir)
983 .unwrap_or_else(|error| panic!("reading {}: {error}", dir.display()));
984 for entry in entries {
985 let path = entry.unwrap().path();
986 if path.is_dir() {
987 stack.push(path);
988 continue;
989 }
990 if path.extension().is_some_and(|extension| extension == "rs") {
991 checked += 1;
992 if path
995 .file_name()
996 .is_some_and(|name| name == "workdir_writes.rs" || name == "workdir_fs.rs")
997 {
998 continue;
999 }
1000 let source = std::fs::read_to_string(&path).unwrap();
1001 let text = production_text(&source);
1002 if workdir_join_arguments(text).is_empty()
1003 && helper_write_arguments(text).is_empty()
1004 {
1005 continue;
1006 }
1007 let relative = path
1008 .strip_prefix(&root)
1009 .unwrap()
1010 .to_string_lossy()
1011 .replace('\\', "/");
1012 assert!(
1013 SCANNED.iter().any(|(file, _)| *file == relative),
1014 "src/{relative} writes into a workdir but is not in \
1015 every_workdir_write_is_declared_or_exempted's SCANNED list, so nothing \
1016 checks its entries against the declaration. Add \
1017 (\"{relative}\", include_str!(\"{relative}\")) to it."
1018 );
1019 }
1020 }
1021 }
1022 assert!(
1023 checked >= 40,
1024 "walked only {checked} source files under src/ — the walk is broken"
1025 );
1026 }
1027
1028 const RAW_FS_EXEMPT: &[(&str, &str, &str)] = &[
1034 (
1035 "network_policy.rs",
1036 "resolve_scoped_dir",
1037 "Creates a declared `capabilities.filesystem.scope` beneath the root a WASI guest is \
1038 preopened at. The preopen itself is opened by path by `wasmtime-wasi`, so a root \
1039 here would hold nothing the guest then gets; the scope was validated as relative and \
1040 non-escaping at staging.",
1041 ),
1042 (
1043 "resource_plane.rs",
1044 "open_no_follow",
1045 "The resource plane's own reads of files the capsule exported, under its own \
1046 `SymlinkPolicy`; it resolves and confines every path itself and writes nothing.",
1047 ),
1048 (
1049 "resource_plane.rs",
1050 "list_export_files",
1051 "The resource plane's listing of a declared export root, on `open_no_follow`'s \
1052 terms.",
1053 ),
1054 (
1055 "retention.rs",
1056 "prune_formations",
1057 "A formation directory under `~/.murmur/formations` and its records subtree under `~/.murmur/records`, outside every workdir.",
1058 ),
1059 (
1060 "retention.rs",
1061 "prune_records",
1062 "A conversation record directory under `~/.murmur/conversations`, outside every workdir.",
1063 ),
1064 (
1065 "retention.rs",
1066 "remove_record",
1067 "A conversation record directory, on `prune_records`'s terms.",
1068 ),
1069 (
1070 "retention.rs",
1071 "list_records",
1072 "The conversation record root, on `prune_records`'s terms.",
1073 ),
1074 (
1075 "retention.rs",
1076 "child_dirs",
1077 "Directories beneath the conversation record root, on `prune_records`'s terms.",
1078 ),
1079 (
1080 "retention.rs",
1081 "locate_message",
1082 "A conversation record file, on `prune_records`'s terms.",
1083 ),
1084 (
1085 "retention.rs",
1086 "count_messages",
1087 "A conversation record file, on `prune_records`'s terms.",
1088 ),
1089 (
1090 "retention.rs",
1091 "stage_truncation",
1092 "A conversation record file being truncated, on `prune_records`'s terms.",
1093 ),
1094 (
1095 "retention.rs",
1096 "stage_with_mode",
1097 "`StagedRewrite`, the sibling-file rewrite of a file under `~/.murmur` — a conversation record or a private file — outside every workdir.",
1098 ),
1099 (
1100 "retention.rs",
1101 "commit",
1102 "`StagedRewrite`'s rename into place, on `stage_with_mode`'s terms.",
1103 ),
1104 (
1105 "retention.rs",
1106 "drop",
1107 "`StagedRewrite`'s removal of an uncommitted sibling file, on `stage_with_mode`'s terms.",
1108 ),
1109 (
1110 "runtime.rs",
1111 "check_resume_launchable",
1112 "A conversation record or harness-session file under `~/.murmur/conversations`, \
1113 outside every workdir.",
1114 ),
1115 (
1116 "runtime.rs",
1117 "copy_manifest_into_runtime_dir",
1118 "Reads the project's `murmur.yaml` from the manifest directory; the copy into the \
1119 runtime directory goes through `WorkdirRoot`.",
1120 ),
1121 (
1122 "runtime.rs",
1123 "load_local_skill_md",
1124 "A local skill source named relative to the manifest directory, read before any \
1125 workdir exists.",
1126 ),
1127 (
1128 "runtime.rs",
1129 "read_prompt_file",
1130 "A prompt file named relative to the manifest directory.",
1131 ),
1132 (
1133 "runtime.rs",
1134 "stage_session",
1135 "The operator's `--workdir` itself, checked by path before it is opened as the \
1136 accessible root, and `<manifest dir>/workdir/<session id>`, created before any root \
1137 exists. Nothing beneath either is touched by path.",
1138 ),
1139 (
1140 "sandbox.rs",
1141 "test_runtime_dir",
1142 "Test-only: creates a runtime directory's three subdirectories under a temporary \
1143 directory the test owns.",
1144 ),
1145 (
1146 "sandbox.rs",
1147 "build_sealed_root",
1148 "Host directories offered as the composed root's base, outside every workdir.",
1149 ),
1150 (
1151 "sandbox.rs",
1152 "open_o_path",
1153 "An `O_PATH` handle for a Landlock rule, opened from a host path the grant names; it \
1154 reads and writes nothing.",
1155 ),
1156 (
1157 "sandbox.rs",
1158 "resolve_landlock_grants_outside",
1159 "The ELF images of host binaries and libraries a shell grant names, read for their \
1160 dependencies.",
1161 ),
1162 (
1163 "sandbox.rs",
1164 "resolve_soname",
1165 "Host library search directories, outside every workdir.",
1166 ),
1167 (
1168 "sealed.rs",
1169 "inspect_installed_profile",
1170 "The host's installed AppArmor profile under `/etc/apparmor.d`.",
1171 ),
1172 (
1173 "sealed.rs",
1174 "inspect_profile_attachment",
1175 "`/proc/self/attr/current`, the runtime's own AppArmor attachment.",
1176 ),
1177 (
1178 "sealed.rs",
1179 "read_trimmed",
1180 "Host settings under `/sys` and `/proc`.",
1181 ),
1182 ];
1183
1184 const RAW_FS_FUNCTIONS: &[&str] = &[
1187 "write(",
1188 "copy(",
1189 "rename(",
1190 "create_dir_all(",
1191 "create_dir(",
1192 "set_permissions(",
1193 "remove_file(",
1194 "remove_dir_all(",
1195 "remove_dir(",
1196 "hard_link(",
1197 "symlink(",
1198 "read_to_string(",
1199 "read(",
1200 ];
1201
1202 const RAW_FS_CALLS: &[&str] = &[
1204 "File::create(",
1205 "File::options(",
1206 "OpenOptions::new(",
1207 "File::open(",
1208 "try_exists(",
1209 ];
1210
1211 const RAW_FS_PREDICATES: &[&str] = &[".exists()", ".is_file()", ".is_dir()"];
1214
1215 fn enclosing_fn(source: &str, at: usize) -> &str {
1217 let before = &source[..at];
1218 let mut cursor = before.len();
1219 while let Some(index) = before[..cursor].rfind("fn ") {
1220 cursor = index;
1221 let boundary = index == 0
1222 || !before.as_bytes()[index - 1].is_ascii_alphanumeric()
1223 && before.as_bytes()[index - 1] != b'_';
1224 let name: String = before[index + 3..]
1225 .chars()
1226 .take_while(|character| character.is_ascii_alphanumeric() || *character == '_')
1227 .collect();
1228 if boundary && !name.is_empty() {
1229 return &before[index + 3..index + 3 + name.len()];
1230 }
1231 }
1232 "<file level>"
1233 }
1234
1235 fn raw_fs_calls(source: &str) -> Vec<(String, String)> {
1237 let mut found = Vec::new();
1238 for function in RAW_FS_FUNCTIONS {
1239 let pattern = format!("fs::{function}");
1240 for (at, _) in source.match_indices(&pattern) {
1241 found.push((enclosing_fn(source, at).to_string(), pattern.clone()));
1242 }
1243 }
1244 for call in RAW_FS_CALLS {
1245 for (at, _) in source.match_indices(call) {
1246 found.push((enclosing_fn(source, at).to_string(), call.to_string()));
1247 }
1248 }
1249 for predicate in RAW_FS_PREDICATES {
1250 for (at, _) in source.match_indices(predicate) {
1251 let receiver = source[..at]
1252 .trim_end()
1253 .rsplit(|character: char| {
1254 !(character.is_ascii_alphanumeric() || "_().".contains(character))
1255 })
1256 .next()
1257 .unwrap_or_default();
1258 let last = receiver
1259 .trim_end_matches("()")
1260 .rsplit('.')
1261 .next()
1262 .unwrap_or_default();
1263 if matches!(last, "meta" | "metadata" | "file_type") {
1264 continue;
1265 }
1266 found.push((enclosing_fn(source, at).to_string(), predicate.to_string()));
1267 }
1268 }
1269 found
1270 }
1271
1272 #[test]
1280 fn no_workdir_file_bypasses_the_no_follow_helper() {
1281 let mut rows_used = vec![false; RAW_FS_EXEMPT.len()];
1282 for (file, source) in SCANNED {
1283 for (function, call) in raw_fs_calls(production_text(source)) {
1284 let row = RAW_FS_EXEMPT
1285 .iter()
1286 .position(|(exempt_file, exempt_fn, _)| {
1287 exempt_file == file && *exempt_fn == function
1288 });
1289 let Some(row) = row else {
1290 panic!(
1291 "{file}: `{function}` calls `{call}` directly. A path beneath the \
1292 accessible workdir or the session directory goes through WorkdirRoot \
1293 (crate::workdir_fs), which follows no link; a path outside every \
1294 workdir gets a ({file:?}, {function:?}, <why it is not beneath a \
1295 workdir>) row in RAW_FS_EXEMPT."
1296 );
1297 };
1298 rows_used[row] = true;
1299 }
1300 }
1301 for ((file, function, _), used) in RAW_FS_EXEMPT.iter().zip(rows_used) {
1302 assert!(
1303 used,
1304 "RAW_FS_EXEMPT row ({file:?}, {function:?}) matches no raw filesystem call — \
1305 the call is gone or moved; remove or re-key the row"
1306 );
1307 }
1308 }
1309
1310 #[test]
1311 fn the_raw_call_scanner_names_the_function_and_the_call() {
1312 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";
1313 assert_eq!(
1314 raw_fs_calls(source),
1315 vec![("staged".to_string(), "fs::write(".to_string())]
1316 );
1317 assert_eq!(
1318 helper_write_arguments(source),
1319 vec!["\"plans/x\"".to_string()]
1320 );
1321 assert_eq!(resolve_entry_name("\"plans/x\"").as_deref(), Some("plans"));
1322 assert_eq!(
1323 resolve_entry_name("format!(\"{BLOB_DIR_NAME}/{sha256}\")").as_deref(),
1324 Some("blobs")
1325 );
1326 assert_eq!(
1327 resolve_entry_name("format!(\"tools/{name}/x\")").as_deref(),
1328 Some("tools")
1329 );
1330 assert_eq!(
1331 resolve_entry_name("CHILDREN_DIR").as_deref(),
1332 Some(".murmur")
1333 );
1334 assert_eq!(resolve_entry_name("&rel"), None);
1335 assert_eq!(resolve_entry_name("format!(\"{}/x\", a)"), None);
1336 }
1337
1338 #[test]
1341 fn the_sealed_tier_adds_exactly_the_tmp_store() {
1342 let scoped = runtime_writes(EnforcementTier::KernelFull);
1343 let sealed = runtime_writes(EnforcementTier::KernelSealed);
1344
1345 assert!(
1346 scoped.iter().all(|write| sealed.contains(write)),
1347 "the sealed declaration must be a superset of the scoped one"
1348 );
1349 let added: Vec<&RuntimeWriteReport> = sealed
1350 .iter()
1351 .filter(|write| !scoped.contains(write))
1352 .collect();
1353 assert_eq!(
1354 added
1355 .iter()
1356 .map(|write| write.path.as_str())
1357 .collect::<Vec<_>>(),
1358 vec![".murmur/<session-id>/.mur-tmp"]
1359 );
1360 for write in added {
1361 assert_eq!(write.condition, RuntimeWriteCondition::Sealed);
1362 assert_eq!(write.scope, RuntimeWriteScope::Session);
1363 assert_eq!(write.kind, RuntimeWriteKind::Directory);
1364 }
1365 }
1366
1367 #[test]
1370 fn runtime_writes_lists_none_of_the_runtime_directory_files() {
1371 for tier in [
1372 EnforcementTier::KernelSealed,
1373 EnforcementTier::KernelFull,
1374 EnforcementTier::KernelSeccompOnly,
1375 EnforcementTier::EnvironmentOnly,
1376 ] {
1377 for write in runtime_writes(tier) {
1378 let last = write.path.rsplit('/').next().unwrap_or_default();
1379 assert!(
1380 !MOVED_OUT_OF_THE_WORKDIR.contains(&last),
1381 "{} is declared as a workdir write",
1382 write.path
1383 );
1384 }
1385 }
1386 }
1387
1388 #[test]
1391 fn only_the_sealed_tier_changes_the_declared_set() {
1392 let baseline = runtime_writes(EnforcementTier::EnvironmentOnly);
1393 assert_eq!(baseline, runtime_writes(EnforcementTier::KernelSeccompOnly));
1394 assert_eq!(baseline, runtime_writes(EnforcementTier::KernelFull));
1395 assert!(baseline
1396 .iter()
1397 .all(|write| write.condition != RuntimeWriteCondition::Sealed));
1398 }
1399
1400 #[test]
1407 fn a_serialized_row_carries_exactly_path_kind_scope_and_condition() {
1408 for write in runtime_writes(EnforcementTier::KernelSealed) {
1409 let line = serde_json::to_string(&write).unwrap();
1410 assert_eq!(
1411 line,
1412 format!(
1413 r#"{{"path":"{}","kind":"{}","scope":"{}","condition":"{}"}}"#,
1414 write.path,
1415 write.kind.wire_name(),
1416 write.scope.wire_name(),
1417 write.condition.wire_name()
1418 )
1419 );
1420 }
1421 }
1422
1423 #[test]
1426 fn session_scoped_paths_carry_the_murmur_prefix_and_the_literal_session_id() {
1427 for write in runtime_writes(EnforcementTier::KernelSealed) {
1428 match write.scope {
1429 RuntimeWriteScope::Session => {
1430 let prefix = format!(".murmur/{SESSION_ID_PLACEHOLDER}/");
1431 assert!(
1432 write.path.starts_with(&prefix),
1433 "{} is session-scoped but not under {prefix}",
1434 write.path
1435 );
1436 }
1437 RuntimeWriteScope::Accessible => assert!(
1438 !write
1439 .path
1440 .starts_with(&format!(".murmur/{SESSION_ID_PLACEHOLDER}/")),
1441 "{} is accessible-scoped but sits inside the session directory",
1442 write.path
1443 ),
1444 }
1445 assert!(
1446 !write.path.contains("ses_"),
1447 "{} carries a real session id where the placeholder belongs",
1448 write.path
1449 );
1450 }
1451 }
1452
1453 #[test]
1456 fn no_declared_path_is_reported_twice() {
1457 let writes = runtime_writes(EnforcementTier::KernelSealed);
1458 let mut paths: Vec<&str> = writes.iter().map(|write| write.path.as_str()).collect();
1459 paths.sort_unstable();
1460 let count = paths.len();
1461 paths.dedup();
1462 assert_eq!(paths.len(), count, "a path is declared twice");
1463 }
1464
1465 #[test]
1466 fn the_rendering_names_every_declared_path_and_the_placeholder_rule() {
1467 let writes = runtime_writes(EnforcementTier::KernelSealed);
1468 let rendered = render_runtime_writes(&writes);
1469 assert!(rendered.contains("`<session-id>` is a literal segment"));
1470 for write in &writes {
1471 assert!(
1472 rendered.contains(&write.path),
1473 "{} is missing from the rendering",
1474 write.path
1475 );
1476 }
1477 assert!(rendered.ends_with('\n'));
1478 }
1479}