1use serde::Serialize;
42
43use crate::sandbox::EnforcementTier;
44
45pub const SESSION_ID_PLACEHOLDER: &str = "<session-id>";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum RuntimeWriteKind {
58 File,
59 Directory,
60}
61
62impl RuntimeWriteKind {
63 #[must_use]
65 pub fn wire_name(self) -> &'static str {
66 match self {
67 Self::File => "file",
68 Self::Directory => "directory",
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "kebab-case")]
81pub enum RuntimeWriteScope {
82 Accessible,
84 Session,
86}
87
88impl RuntimeWriteScope {
89 #[must_use]
91 pub fn wire_name(self) -> &'static str {
92 match self {
93 Self::Accessible => "accessible",
94 Self::Session => "session",
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "kebab-case")]
105pub enum RuntimeWriteCondition {
106 Always,
108 WorkdirProvided,
110 AgentSession,
112 ScriptSession,
114 Shell,
116 PeerFetch,
118 Spawn,
120 Delegated,
122 Sealed,
124}
125
126impl RuntimeWriteCondition {
127 #[must_use]
129 pub fn wire_name(self) -> &'static str {
130 match self {
131 Self::Always => "always",
132 Self::WorkdirProvided => "workdir-provided",
133 Self::AgentSession => "agent-session",
134 Self::ScriptSession => "script-session",
135 Self::Shell => "shell",
136 Self::PeerFetch => "peer-fetch",
137 Self::Spawn => "spawn",
138 Self::Delegated => "delegated",
139 Self::Sealed => "sealed",
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148pub struct RuntimeWriteReport {
149 pub path: String,
151 pub kind: RuntimeWriteKind,
152 pub scope: RuntimeWriteScope,
153 pub condition: RuntimeWriteCondition,
154}
155
156struct DeclaredWrite {
162 entry: &'static str,
163 kind: RuntimeWriteKind,
164 scope: RuntimeWriteScope,
165 condition: RuntimeWriteCondition,
166}
167
168impl DeclaredWrite {
169 fn report(&self) -> RuntimeWriteReport {
170 let path = match self.scope {
171 RuntimeWriteScope::Accessible => self.entry.to_string(),
172 RuntimeWriteScope::Session => {
173 format!(".murmur/{SESSION_ID_PLACEHOLDER}/{}", self.entry)
174 }
175 };
176 RuntimeWriteReport {
177 path,
178 kind: self.kind,
179 scope: self.scope,
180 condition: self.condition,
181 }
182 }
183
184 #[cfg(test)]
187 fn first_segment(&self) -> &'static str {
188 self.entry.split('/').next().unwrap_or(self.entry)
189 }
190}
191
192const DECLARED_WRITES: &[DeclaredWrite] = &[
198 DeclaredWrite {
200 entry: ".murmur/<session-id>",
202 kind: RuntimeWriteKind::Directory,
203 scope: RuntimeWriteScope::Accessible,
204 condition: RuntimeWriteCondition::Always,
205 },
206 DeclaredWrite {
207 entry: "task.md",
210 kind: RuntimeWriteKind::File,
211 scope: RuntimeWriteScope::Accessible,
212 condition: RuntimeWriteCondition::Always,
213 },
214 DeclaredWrite {
215 entry: "murmur.yaml",
219 kind: RuntimeWriteKind::File,
220 scope: RuntimeWriteScope::Accessible,
221 condition: RuntimeWriteCondition::WorkdirProvided,
222 },
223 DeclaredWrite {
224 entry: "logs",
228 kind: RuntimeWriteKind::Directory,
229 scope: RuntimeWriteScope::Accessible,
230 condition: RuntimeWriteCondition::Shell,
231 },
232 DeclaredWrite {
233 entry: "peer-in",
235 kind: RuntimeWriteKind::Directory,
236 scope: RuntimeWriteScope::Accessible,
237 condition: RuntimeWriteCondition::PeerFetch,
238 },
239 DeclaredWrite {
240 entry: ".murmur/children",
244 kind: RuntimeWriteKind::Directory,
245 scope: RuntimeWriteScope::Accessible,
246 condition: RuntimeWriteCondition::Spawn,
247 },
248 DeclaredWrite {
249 entry: "out",
252 kind: RuntimeWriteKind::Directory,
253 scope: RuntimeWriteScope::Accessible,
254 condition: RuntimeWriteCondition::ScriptSession,
255 },
256 DeclaredWrite {
257 entry: "completion.json",
259 kind: RuntimeWriteKind::File,
260 scope: RuntimeWriteScope::Accessible,
261 condition: RuntimeWriteCondition::Delegated,
262 },
263 DeclaredWrite {
265 entry: "tools",
266 kind: RuntimeWriteKind::Directory,
267 scope: RuntimeWriteScope::Session,
268 condition: RuntimeWriteCondition::Always,
269 },
270 DeclaredWrite {
271 entry: "trace.jsonl",
272 kind: RuntimeWriteKind::File,
273 scope: RuntimeWriteScope::Session,
274 condition: RuntimeWriteCondition::Always,
275 },
276 DeclaredWrite {
277 entry: "blobs",
279 kind: RuntimeWriteKind::Directory,
280 scope: RuntimeWriteScope::Session,
281 condition: RuntimeWriteCondition::Always,
282 },
283 DeclaredWrite {
284 entry: "logs",
286 kind: RuntimeWriteKind::Directory,
287 scope: RuntimeWriteScope::Session,
288 condition: RuntimeWriteCondition::AgentSession,
289 },
290 DeclaredWrite {
291 entry: "out",
293 kind: RuntimeWriteKind::Directory,
294 scope: RuntimeWriteScope::Session,
295 condition: RuntimeWriteCondition::AgentSession,
296 },
297 DeclaredWrite {
298 entry: "MURMUR.md",
299 kind: RuntimeWriteKind::File,
300 scope: RuntimeWriteScope::Session,
301 condition: RuntimeWriteCondition::AgentSession,
302 },
303 DeclaredWrite {
304 entry: "plans",
306 kind: RuntimeWriteKind::Directory,
307 scope: RuntimeWriteScope::Session,
308 condition: RuntimeWriteCondition::AgentSession,
309 },
310 DeclaredWrite {
311 entry: ".capsule-home",
314 kind: RuntimeWriteKind::Directory,
315 scope: RuntimeWriteScope::Session,
316 condition: RuntimeWriteCondition::Shell,
317 },
318 DeclaredWrite {
319 entry: ".mur-tmp",
322 kind: RuntimeWriteKind::Directory,
323 scope: RuntimeWriteScope::Session,
324 condition: RuntimeWriteCondition::Sealed,
325 },
326 DeclaredWrite {
327 entry: ".mur-etc",
329 kind: RuntimeWriteKind::Directory,
330 scope: RuntimeWriteScope::Session,
331 condition: RuntimeWriteCondition::Sealed,
332 },
333];
334
335pub(crate) fn runtime_writes(tier: EnforcementTier) -> Vec<RuntimeWriteReport> {
346 let sealed = tier == EnforcementTier::KernelSealed;
347 DECLARED_WRITES
348 .iter()
349 .filter(|declared| sealed || declared.condition != RuntimeWriteCondition::Sealed)
350 .map(DeclaredWrite::report)
351 .collect()
352}
353
354#[must_use]
360pub fn render_runtime_writes(writes: &[RuntimeWriteReport]) -> String {
361 let mut out = String::new();
362 out.push_str(
363 " paths are relative to the accessible workdir; `<session-id>` is a literal segment, \
364 replaced by the run's own session id\n",
365 );
366 if writes.is_empty() {
367 out.push_str(" runtime writes: <none>\n");
368 return out;
369 }
370 out.push_str(" runtime writes:\n");
371 for write in writes {
372 out.push_str(&format!(
373 " - {} ({}, {}, {})\n",
374 write.path,
375 write.kind.wire_name(),
376 write.scope.wire_name(),
377 write.condition.wire_name()
378 ));
379 }
380 out
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 const SCANNED: &[(&str, &str)] = &[
393 ("agent.rs", include_str!("agent.rs")),
394 ("agent/inventory.rs", include_str!("agent/inventory.rs")),
395 ("agent/process.rs", include_str!("agent/process.rs")),
396 ("child_launch.rs", include_str!("child_launch.rs")),
397 ("delegation.rs", include_str!("delegation.rs")),
398 ("hooks.rs", include_str!("hooks.rs")),
399 ("murmur_md.rs", include_str!("murmur_md.rs")),
400 ("otel.rs", include_str!("otel.rs")),
401 ("plan.rs", include_str!("plan.rs")),
402 ("protected_paths.rs", include_str!("protected_paths.rs")),
403 ("recipes.rs", include_str!("recipes.rs")),
404 ("resource_plane.rs", include_str!("resource_plane.rs")),
405 ("runtime.rs", include_str!("runtime.rs")),
406 ("sandbox.rs", include_str!("sandbox.rs")),
407 ("sealed.rs", include_str!("sealed.rs")),
408 ("shell.rs", include_str!("shell.rs")),
409 ("tool_annotations.rs", include_str!("tool_annotations.rs")),
410 ("trace.rs", include_str!("trace.rs")),
411 ("trace_blobs.rs", include_str!("trace_blobs.rs")),
412 ];
413
414 const CONSTANTS: &[(&str, &str)] = &[
420 ("MANIFEST_FILENAME", murmur_artifact::MANIFEST_FILENAME),
421 ("COMPLETION_FILE", crate::delegation::COMPLETION_FILE),
422 ("BLOB_DIR_NAME", crate::trace_blobs::BLOB_DIR_NAME),
423 ("SEALED_TMP_DIR_NAME", crate::sealed::SEALED_TMP_DIR_NAME),
424 (
425 "SEALED_ETC_STAGING_DIR_NAME",
426 crate::sealed::SEALED_ETC_STAGING_DIR_NAME,
427 ),
428 (
429 "SYNTHETIC_HOME_DIR_NAME",
430 crate::shell::SYNTHETIC_HOME_DIR_NAME,
431 ),
432 ];
433
434 const EXEMPT: &[(&str, &str)] = &[
436 (
437 "input.txt",
438 "Read, never written: the fallback name `read_task` tries after `task.md`. Source \
439 text cannot tell a read from a write, so a path the runtime only ever opens for \
440 reading is exempted here rather than declared as something the runtime produces.",
441 ),
442 (
443 "package.json",
444 "Read, never written: the recipe detector opens it to learn which scripts a workdir \
445 already declares. Exempt on `input.txt`'s terms.",
446 ),
447 ];
448
449 const DYNAMIC_EXEMPT: &[(&str, &str, &str)] = &[
452 (
453 "protected_paths.rs",
454 "&rule.relative",
455 "A `capabilities.filesystem.read_only` path from the manifest, resolved to decide \
456 whether a call is refused. The runtime reads it and writes nothing.",
457 ),
458 (
459 "protected_paths.rs",
460 "&lexical",
461 "The lexically normalised form of a path a tool call named, resolved for the same \
462 refusal decision.",
463 ),
464 (
465 "recipes.rs",
466 "dir",
467 "A directory name from the recipe detector's own candidate list, opened to see \
468 whether the workdir has it.",
469 ),
470 (
471 "resource_plane.rs",
472 "declared_root",
473 "The `exports.files` / `exports.peer_files` root a manifest declares. The plane \
474 serves what the capsule put there; it creates nothing of its own.",
475 ),
476 (
477 "resource_plane.rs",
478 "&export.root",
479 "The same declared root, resolved when a request is served. Exempt on \
480 `declared_root`'s terms.",
481 ),
482 (
483 "runtime.rs",
484 "&relative",
485 "A resource path a tool call named, resolved to check it is inside the workdir \
486 before the file is read.",
487 ),
488 (
489 "runtime.rs",
490 "&stored_path",
491 "Where a redeemed peer handle's bytes go, composed by `peer_handoff::stored_path_for` \
492 under `peer-in/`. Declared through that directory's own entry, which is the name a \
493 consumer excludes.",
494 ),
495 (
496 "shell.rs",
497 "&output_path",
498 "A demoted command's own log path, `logs/<work-id>.log`, composed by \
499 `detached::output_path_for` and joined here only to measure the file's size. \
500 Declared through the `logs` entry.",
501 ),
502 (
503 "delegation.rs",
504 "format!( \".{COMPLETION_FILE}.{}.tmp\", uuid::Uuid::now_v7().simple() )",
505 "The temporary file `completion.json` is written through and renamed from. It exists \
506 only between two syscalls in the same function, so a consumer diffing a finished \
507 run never sees it.",
508 ),
509 ];
510
511 fn production_text(source: &str) -> &str {
517 match source.find("#[cfg(test)]\nmod tests {") {
518 Some(index) => &source[..index],
519 None => source,
520 }
521 }
522
523 fn workdir_join_arguments(source: &str) -> Vec<String> {
529 let mut found = Vec::new();
530 let bytes = source.as_bytes();
531 let mut cursor = 0;
532 while let Some(offset) = source[cursor..].find(".join(") {
533 let start = cursor + offset;
534 cursor = start + ".join(".len();
535 if !source[..start].trim_end().ends_with("workdir") {
536 continue;
537 }
538 let mut depth = 1usize;
539 let mut index = cursor;
540 while index < bytes.len() && depth > 0 {
541 match bytes[index] {
542 b'(' => depth += 1,
543 b')' => depth -= 1,
544 _ => {}
545 }
546 index += 1;
547 }
548 assert!(
549 depth == 0,
550 "unbalanced parentheses after a join into a workdir"
551 );
552 found.push(
555 source[cursor..index - 1]
556 .split_whitespace()
557 .collect::<Vec<_>>()
558 .join(" "),
559 );
560 }
561 found
562 }
563
564 fn resolve_entry_name(argument: &str) -> Option<String> {
567 if let Some(rest) = argument.strip_prefix('"') {
568 return rest.split_once('"').map(|(literal, _)| literal.to_string());
569 }
570 let last = argument.rsplit("::").next().unwrap_or(argument);
571 let is_constant_name = !last.is_empty()
572 && last
573 .chars()
574 .all(|character| character.is_ascii_uppercase() || character == '_');
575 if !is_constant_name {
576 return None;
577 }
578 Some(
579 CONSTANTS
580 .iter()
581 .find(|(name, _)| *name == last)
582 .unwrap_or_else(|| {
583 panic!(
584 "a workdir.join({argument}) names the constant {last}, which this test's \
585 CONSTANTS table does not pair with a value. Add \
586 (\"{last}\", <path to the constant>) so a rename or a value change is \
587 caught here rather than silently accepted."
588 )
589 })
590 .1
591 .to_string(),
592 )
593 }
594
595 #[test]
601 fn every_workdir_write_is_declared_or_exempted() {
602 let declared: Vec<&str> = DECLARED_WRITES
603 .iter()
604 .map(DeclaredWrite::first_segment)
605 .collect();
606
607 let mut scanned = 0usize;
608 for (file, source) in SCANNED {
609 for argument in workdir_join_arguments(production_text(source)) {
610 scanned += 1;
611 let Some(entry) = resolve_entry_name(&argument) else {
612 assert!(
613 DYNAMIC_EXEMPT
614 .iter()
615 .any(|(exempt_file, exempt_argument, _)| exempt_file == file
616 && *exempt_argument == argument),
617 "{file} writes workdir.join({argument}) into a workdir, and the argument \
618 is neither a string literal nor a named constant, so this test cannot \
619 say what it names. Add ({file:?}, {argument:?}, <written reason>) to \
620 this test's DYNAMIC_EXEMPT table."
621 );
622 continue;
623 };
624 assert!(
625 declared.contains(&entry.as_str())
626 || EXEMPT.iter().any(|(name, _)| *name == entry),
627 "{file} writes `{entry}` into a workdir, and no --explain-scope report \
628 declares it. Either add an entry for `{entry}` to DECLARED_WRITES in \
629 workdir_writes.rs, so a consumer diffing the workdir can subtract it, or add \
630 it to this test's EXEMPT table with a written reason — a path the runtime \
631 only reads is the usual one."
632 );
633 }
634 }
635
636 assert!(
638 scanned >= 50,
639 "the workdir.join scan found only {scanned} occurrences across {} files, far below \
640 what the runtime has — the scanner is broken, not the runtime",
641 SCANNED.len()
642 );
643
644 for (name, _) in EXEMPT {
645 assert!(
646 !declared.contains(name),
647 "`{name}` is listed as exempt and is also declared — remove one"
648 );
649 }
650 }
651
652 #[test]
658 fn every_source_file_that_joins_a_workdir_is_scanned() {
659 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
660 let mut stack = vec![root.clone()];
661 let mut checked = 0usize;
662 while let Some(dir) = stack.pop() {
663 let entries = std::fs::read_dir(&dir)
664 .unwrap_or_else(|error| panic!("reading {}: {error}", dir.display()));
665 for entry in entries {
666 let path = entry.unwrap().path();
667 if path.is_dir() {
668 stack.push(path);
669 continue;
670 }
671 if path.extension().is_some_and(|extension| extension == "rs") {
672 checked += 1;
673 if path
676 .file_name()
677 .is_some_and(|name| name == "workdir_writes.rs")
678 {
679 continue;
680 }
681 let source = std::fs::read_to_string(&path).unwrap();
682 if workdir_join_arguments(production_text(&source)).is_empty() {
683 continue;
684 }
685 let relative = path
686 .strip_prefix(&root)
687 .unwrap()
688 .to_string_lossy()
689 .replace('\\', "/");
690 assert!(
691 SCANNED.iter().any(|(file, _)| *file == relative),
692 "src/{relative} writes into a workdir but is not in \
693 every_workdir_write_is_declared_or_exempted's SCANNED list, so nothing \
694 checks its entries against the declaration. Add \
695 (\"{relative}\", include_str!(\"{relative}\")) to it."
696 );
697 }
698 }
699 }
700 assert!(
701 checked >= 40,
702 "walked only {checked} source files under src/ — the walk is broken"
703 );
704 }
705
706 #[test]
709 fn the_sealed_tier_adds_exactly_the_two_sealed_session_entries() {
710 let scoped = runtime_writes(EnforcementTier::KernelFull);
711 let sealed = runtime_writes(EnforcementTier::KernelSealed);
712
713 assert!(
714 scoped.iter().all(|write| sealed.contains(write)),
715 "the sealed declaration must be a superset of the scoped one"
716 );
717 let added: Vec<&RuntimeWriteReport> = sealed
718 .iter()
719 .filter(|write| !scoped.contains(write))
720 .collect();
721 assert_eq!(
722 added
723 .iter()
724 .map(|write| write.path.as_str())
725 .collect::<Vec<_>>(),
726 vec![
727 ".murmur/<session-id>/.mur-tmp",
728 ".murmur/<session-id>/.mur-etc"
729 ]
730 );
731 for write in added {
732 assert_eq!(write.condition, RuntimeWriteCondition::Sealed);
733 assert_eq!(write.scope, RuntimeWriteScope::Session);
734 assert_eq!(write.kind, RuntimeWriteKind::Directory);
735 }
736 }
737
738 #[test]
741 fn only_the_sealed_tier_changes_the_declared_set() {
742 let baseline = runtime_writes(EnforcementTier::EnvironmentOnly);
743 assert_eq!(baseline, runtime_writes(EnforcementTier::KernelSeccompOnly));
744 assert_eq!(baseline, runtime_writes(EnforcementTier::KernelFull));
745 assert!(baseline
746 .iter()
747 .all(|write| write.condition != RuntimeWriteCondition::Sealed));
748 }
749
750 #[test]
757 fn a_serialized_row_carries_exactly_path_kind_scope_and_condition() {
758 for write in runtime_writes(EnforcementTier::KernelSealed) {
759 let line = serde_json::to_string(&write).unwrap();
760 assert_eq!(
761 line,
762 format!(
763 r#"{{"path":"{}","kind":"{}","scope":"{}","condition":"{}"}}"#,
764 write.path,
765 write.kind.wire_name(),
766 write.scope.wire_name(),
767 write.condition.wire_name()
768 )
769 );
770 }
771 }
772
773 #[test]
776 fn session_scoped_paths_carry_the_murmur_prefix_and_the_literal_session_id() {
777 for write in runtime_writes(EnforcementTier::KernelSealed) {
778 match write.scope {
779 RuntimeWriteScope::Session => {
780 let prefix = format!(".murmur/{SESSION_ID_PLACEHOLDER}/");
781 assert!(
782 write.path.starts_with(&prefix),
783 "{} is session-scoped but not under {prefix}",
784 write.path
785 );
786 }
787 RuntimeWriteScope::Accessible => assert!(
788 !write
789 .path
790 .starts_with(&format!(".murmur/{SESSION_ID_PLACEHOLDER}/")),
791 "{} is accessible-scoped but sits inside the session directory",
792 write.path
793 ),
794 }
795 assert!(
796 !write.path.contains("ses_"),
797 "{} carries a real session id where the placeholder belongs",
798 write.path
799 );
800 }
801 }
802
803 #[test]
806 fn no_declared_path_is_reported_twice() {
807 let writes = runtime_writes(EnforcementTier::KernelSealed);
808 let mut paths: Vec<&str> = writes.iter().map(|write| write.path.as_str()).collect();
809 paths.sort_unstable();
810 let count = paths.len();
811 paths.dedup();
812 assert_eq!(paths.len(), count, "a path is declared twice");
813 }
814
815 #[test]
816 fn the_rendering_names_every_declared_path_and_the_placeholder_rule() {
817 let writes = runtime_writes(EnforcementTier::KernelSealed);
818 let rendered = render_runtime_writes(&writes);
819 assert!(rendered.contains("`<session-id>` is a literal segment"));
820 for write in &writes {
821 assert!(
822 rendered.contains(&write.path),
823 "{} is missing from the rendering",
824 write.path
825 );
826 }
827 assert!(rendered.ends_with('\n'));
828 }
829}