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: ".process-driver",
302 kind: RuntimeWriteKind::Directory,
303 scope: RuntimeWriteScope::Session,
304 condition: RuntimeWriteCondition::AgentSession,
305 },
306 DeclaredWrite {
307 entry: "MURMUR.md",
308 kind: RuntimeWriteKind::File,
309 scope: RuntimeWriteScope::Session,
310 condition: RuntimeWriteCondition::AgentSession,
311 },
312 DeclaredWrite {
313 entry: "plans",
315 kind: RuntimeWriteKind::Directory,
316 scope: RuntimeWriteScope::Session,
317 condition: RuntimeWriteCondition::AgentSession,
318 },
319 DeclaredWrite {
320 entry: ".capsule-home",
323 kind: RuntimeWriteKind::Directory,
324 scope: RuntimeWriteScope::Session,
325 condition: RuntimeWriteCondition::Shell,
326 },
327 DeclaredWrite {
328 entry: ".mur-tmp",
331 kind: RuntimeWriteKind::Directory,
332 scope: RuntimeWriteScope::Session,
333 condition: RuntimeWriteCondition::Sealed,
334 },
335 DeclaredWrite {
336 entry: ".mur-etc",
338 kind: RuntimeWriteKind::Directory,
339 scope: RuntimeWriteScope::Session,
340 condition: RuntimeWriteCondition::Sealed,
341 },
342];
343
344pub(crate) fn runtime_writes(tier: EnforcementTier) -> Vec<RuntimeWriteReport> {
355 let sealed = tier == EnforcementTier::KernelSealed;
356 DECLARED_WRITES
357 .iter()
358 .filter(|declared| sealed || declared.condition != RuntimeWriteCondition::Sealed)
359 .map(DeclaredWrite::report)
360 .collect()
361}
362
363#[must_use]
369pub fn render_runtime_writes(writes: &[RuntimeWriteReport]) -> String {
370 let mut out = String::new();
371 out.push_str(
372 " paths are relative to the accessible workdir; `<session-id>` is a literal segment, \
373 replaced by the run's own session id\n",
374 );
375 if writes.is_empty() {
376 out.push_str(" runtime writes: <none>\n");
377 return out;
378 }
379 out.push_str(" runtime writes:\n");
380 for write in writes {
381 out.push_str(&format!(
382 " - {} ({}, {}, {})\n",
383 write.path,
384 write.kind.wire_name(),
385 write.scope.wire_name(),
386 write.condition.wire_name()
387 ));
388 }
389 out
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 const SCANNED: &[(&str, &str)] = &[
402 ("agent.rs", include_str!("agent.rs")),
403 ("agent/inventory.rs", include_str!("agent/inventory.rs")),
404 ("agent/process.rs", include_str!("agent/process.rs")),
405 ("child_launch.rs", include_str!("child_launch.rs")),
406 ("delegation.rs", include_str!("delegation.rs")),
407 ("hooks.rs", include_str!("hooks.rs")),
408 ("murmur_md.rs", include_str!("murmur_md.rs")),
409 ("otel.rs", include_str!("otel.rs")),
410 ("plan.rs", include_str!("plan.rs")),
411 ("protected_paths.rs", include_str!("protected_paths.rs")),
412 ("recipes.rs", include_str!("recipes.rs")),
413 ("resource_plane.rs", include_str!("resource_plane.rs")),
414 ("runtime.rs", include_str!("runtime.rs")),
415 ("sandbox.rs", include_str!("sandbox.rs")),
416 ("sealed.rs", include_str!("sealed.rs")),
417 ("shell.rs", include_str!("shell.rs")),
418 ("tool_annotations.rs", include_str!("tool_annotations.rs")),
419 ("trace.rs", include_str!("trace.rs")),
420 ("trace_blobs.rs", include_str!("trace_blobs.rs")),
421 ];
422
423 const CONSTANTS: &[(&str, &str)] = &[
429 ("MANIFEST_FILENAME", murmur_artifact::MANIFEST_FILENAME),
430 ("COMPLETION_FILE", crate::delegation::COMPLETION_FILE),
431 ("BLOB_DIR_NAME", crate::trace_blobs::BLOB_DIR_NAME),
432 ("SEALED_TMP_DIR_NAME", crate::sealed::SEALED_TMP_DIR_NAME),
433 (
434 "SEALED_ETC_STAGING_DIR_NAME",
435 crate::sealed::SEALED_ETC_STAGING_DIR_NAME,
436 ),
437 (
438 "SYNTHETIC_HOME_DIR_NAME",
439 crate::shell::SYNTHETIC_HOME_DIR_NAME,
440 ),
441 ("FILES_DIR_NAME", crate::agent::process::FILES_DIR_NAME),
442 ];
443
444 const EXEMPT: &[(&str, &str)] = &[
446 (
447 "input.txt",
448 "Read, never written: the fallback name `read_task` tries after `task.md`. Source \
449 text cannot tell a read from a write, so a path the runtime only ever opens for \
450 reading is exempted here rather than declared as something the runtime produces.",
451 ),
452 (
453 "package.json",
454 "Read, never written: the recipe detector opens it to learn which scripts a workdir \
455 already declares. Exempt on `input.txt`'s terms.",
456 ),
457 ];
458
459 const DYNAMIC_EXEMPT: &[(&str, &str, &str)] = &[
462 (
463 "protected_paths.rs",
464 "&rule.relative",
465 "A `capabilities.filesystem.read_only` path from the manifest, resolved to decide \
466 whether a call is refused. The runtime reads it and writes nothing.",
467 ),
468 (
469 "protected_paths.rs",
470 "&lexical",
471 "The lexically normalised form of a path a tool call named, resolved for the same \
472 refusal decision.",
473 ),
474 (
475 "recipes.rs",
476 "dir",
477 "A directory name from the recipe detector's own candidate list, opened to see \
478 whether the workdir has it.",
479 ),
480 (
481 "resource_plane.rs",
482 "declared_root",
483 "The `exports.files` / `exports.peer_files` root a manifest declares. The plane \
484 serves what the capsule put there; it creates nothing of its own.",
485 ),
486 (
487 "resource_plane.rs",
488 "&export.root",
489 "The same declared root, resolved when a request is served. Exempt on \
490 `declared_root`'s terms.",
491 ),
492 (
493 "runtime.rs",
494 "&relative",
495 "A resource path a tool call named, resolved to check it is inside the workdir \
496 before the file is read.",
497 ),
498 (
499 "runtime.rs",
500 "&stored_path",
501 "Where a redeemed peer handle's bytes go, composed by `peer_handoff::stored_path_for` \
502 under `peer-in/`. Declared through that directory's own entry, which is the name a \
503 consumer excludes.",
504 ),
505 (
506 "shell.rs",
507 "&output_path",
508 "A demoted command's own log path, `logs/<work-id>.log`, composed by \
509 `detached::output_path_for` and joined here only to measure the file's size. \
510 Declared through the `logs` entry.",
511 ),
512 (
513 "delegation.rs",
514 "format!( \".{COMPLETION_FILE}.{}.tmp\", uuid::Uuid::now_v7().simple() )",
515 "The temporary file `completion.json` is written through and renamed from. It exists \
516 only between two syscalls in the same function, so a consumer diffing a finished \
517 run never sees it.",
518 ),
519 ];
520
521 fn production_text(source: &str) -> &str {
527 match source.find("#[cfg(test)]\nmod tests {") {
528 Some(index) => &source[..index],
529 None => source,
530 }
531 }
532
533 fn workdir_join_arguments(source: &str) -> Vec<String> {
539 let mut found = Vec::new();
540 let bytes = source.as_bytes();
541 let mut cursor = 0;
542 while let Some(offset) = source[cursor..].find(".join(") {
543 let start = cursor + offset;
544 cursor = start + ".join(".len();
545 if !source[..start].trim_end().ends_with("workdir") {
546 continue;
547 }
548 let mut depth = 1usize;
549 let mut index = cursor;
550 while index < bytes.len() && depth > 0 {
551 match bytes[index] {
552 b'(' => depth += 1,
553 b')' => depth -= 1,
554 _ => {}
555 }
556 index += 1;
557 }
558 assert!(
559 depth == 0,
560 "unbalanced parentheses after a join into a workdir"
561 );
562 found.push(
565 source[cursor..index - 1]
566 .split_whitespace()
567 .collect::<Vec<_>>()
568 .join(" "),
569 );
570 }
571 found
572 }
573
574 fn resolve_entry_name(argument: &str) -> Option<String> {
577 if let Some(rest) = argument.strip_prefix('"') {
578 return rest.split_once('"').map(|(literal, _)| literal.to_string());
579 }
580 let last = argument.rsplit("::").next().unwrap_or(argument);
581 let is_constant_name = !last.is_empty()
582 && last
583 .chars()
584 .all(|character| character.is_ascii_uppercase() || character == '_');
585 if !is_constant_name {
586 return None;
587 }
588 Some(
589 CONSTANTS
590 .iter()
591 .find(|(name, _)| *name == last)
592 .unwrap_or_else(|| {
593 panic!(
594 "a workdir.join({argument}) names the constant {last}, which this test's \
595 CONSTANTS table does not pair with a value. Add \
596 (\"{last}\", <path to the constant>) so a rename or a value change is \
597 caught here rather than silently accepted."
598 )
599 })
600 .1
601 .to_string(),
602 )
603 }
604
605 #[test]
611 fn every_workdir_write_is_declared_or_exempted() {
612 let declared: Vec<&str> = DECLARED_WRITES
613 .iter()
614 .map(DeclaredWrite::first_segment)
615 .collect();
616
617 let mut scanned = 0usize;
618 for (file, source) in SCANNED {
619 for argument in workdir_join_arguments(production_text(source)) {
620 scanned += 1;
621 let Some(entry) = resolve_entry_name(&argument) else {
622 assert!(
623 DYNAMIC_EXEMPT
624 .iter()
625 .any(|(exempt_file, exempt_argument, _)| exempt_file == file
626 && *exempt_argument == argument),
627 "{file} writes workdir.join({argument}) into a workdir, and the argument \
628 is neither a string literal nor a named constant, so this test cannot \
629 say what it names. Add ({file:?}, {argument:?}, <written reason>) to \
630 this test's DYNAMIC_EXEMPT table."
631 );
632 continue;
633 };
634 assert!(
635 declared.contains(&entry.as_str())
636 || EXEMPT.iter().any(|(name, _)| *name == entry),
637 "{file} writes `{entry}` into a workdir, and no --explain-scope report \
638 declares it. Either add an entry for `{entry}` to DECLARED_WRITES in \
639 workdir_writes.rs, so a consumer diffing the workdir can subtract it, or add \
640 it to this test's EXEMPT table with a written reason — a path the runtime \
641 only reads is the usual one."
642 );
643 }
644 }
645
646 assert!(
648 scanned >= 50,
649 "the workdir.join scan found only {scanned} occurrences across {} files, far below \
650 what the runtime has — the scanner is broken, not the runtime",
651 SCANNED.len()
652 );
653
654 for (name, _) in EXEMPT {
655 assert!(
656 !declared.contains(name),
657 "`{name}` is listed as exempt and is also declared — remove one"
658 );
659 }
660 }
661
662 #[test]
668 fn every_source_file_that_joins_a_workdir_is_scanned() {
669 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
670 let mut stack = vec![root.clone()];
671 let mut checked = 0usize;
672 while let Some(dir) = stack.pop() {
673 let entries = std::fs::read_dir(&dir)
674 .unwrap_or_else(|error| panic!("reading {}: {error}", dir.display()));
675 for entry in entries {
676 let path = entry.unwrap().path();
677 if path.is_dir() {
678 stack.push(path);
679 continue;
680 }
681 if path.extension().is_some_and(|extension| extension == "rs") {
682 checked += 1;
683 if path
686 .file_name()
687 .is_some_and(|name| name == "workdir_writes.rs")
688 {
689 continue;
690 }
691 let source = std::fs::read_to_string(&path).unwrap();
692 if workdir_join_arguments(production_text(&source)).is_empty() {
693 continue;
694 }
695 let relative = path
696 .strip_prefix(&root)
697 .unwrap()
698 .to_string_lossy()
699 .replace('\\', "/");
700 assert!(
701 SCANNED.iter().any(|(file, _)| *file == relative),
702 "src/{relative} writes into a workdir but is not in \
703 every_workdir_write_is_declared_or_exempted's SCANNED list, so nothing \
704 checks its entries against the declaration. Add \
705 (\"{relative}\", include_str!(\"{relative}\")) to it."
706 );
707 }
708 }
709 }
710 assert!(
711 checked >= 40,
712 "walked only {checked} source files under src/ — the walk is broken"
713 );
714 }
715
716 #[test]
719 fn the_sealed_tier_adds_exactly_the_two_sealed_session_entries() {
720 let scoped = runtime_writes(EnforcementTier::KernelFull);
721 let sealed = runtime_writes(EnforcementTier::KernelSealed);
722
723 assert!(
724 scoped.iter().all(|write| sealed.contains(write)),
725 "the sealed declaration must be a superset of the scoped one"
726 );
727 let added: Vec<&RuntimeWriteReport> = sealed
728 .iter()
729 .filter(|write| !scoped.contains(write))
730 .collect();
731 assert_eq!(
732 added
733 .iter()
734 .map(|write| write.path.as_str())
735 .collect::<Vec<_>>(),
736 vec![
737 ".murmur/<session-id>/.mur-tmp",
738 ".murmur/<session-id>/.mur-etc"
739 ]
740 );
741 for write in added {
742 assert_eq!(write.condition, RuntimeWriteCondition::Sealed);
743 assert_eq!(write.scope, RuntimeWriteScope::Session);
744 assert_eq!(write.kind, RuntimeWriteKind::Directory);
745 }
746 }
747
748 #[test]
751 fn only_the_sealed_tier_changes_the_declared_set() {
752 let baseline = runtime_writes(EnforcementTier::EnvironmentOnly);
753 assert_eq!(baseline, runtime_writes(EnforcementTier::KernelSeccompOnly));
754 assert_eq!(baseline, runtime_writes(EnforcementTier::KernelFull));
755 assert!(baseline
756 .iter()
757 .all(|write| write.condition != RuntimeWriteCondition::Sealed));
758 }
759
760 #[test]
767 fn a_serialized_row_carries_exactly_path_kind_scope_and_condition() {
768 for write in runtime_writes(EnforcementTier::KernelSealed) {
769 let line = serde_json::to_string(&write).unwrap();
770 assert_eq!(
771 line,
772 format!(
773 r#"{{"path":"{}","kind":"{}","scope":"{}","condition":"{}"}}"#,
774 write.path,
775 write.kind.wire_name(),
776 write.scope.wire_name(),
777 write.condition.wire_name()
778 )
779 );
780 }
781 }
782
783 #[test]
786 fn session_scoped_paths_carry_the_murmur_prefix_and_the_literal_session_id() {
787 for write in runtime_writes(EnforcementTier::KernelSealed) {
788 match write.scope {
789 RuntimeWriteScope::Session => {
790 let prefix = format!(".murmur/{SESSION_ID_PLACEHOLDER}/");
791 assert!(
792 write.path.starts_with(&prefix),
793 "{} is session-scoped but not under {prefix}",
794 write.path
795 );
796 }
797 RuntimeWriteScope::Accessible => assert!(
798 !write
799 .path
800 .starts_with(&format!(".murmur/{SESSION_ID_PLACEHOLDER}/")),
801 "{} is accessible-scoped but sits inside the session directory",
802 write.path
803 ),
804 }
805 assert!(
806 !write.path.contains("ses_"),
807 "{} carries a real session id where the placeholder belongs",
808 write.path
809 );
810 }
811 }
812
813 #[test]
816 fn no_declared_path_is_reported_twice() {
817 let writes = runtime_writes(EnforcementTier::KernelSealed);
818 let mut paths: Vec<&str> = writes.iter().map(|write| write.path.as_str()).collect();
819 paths.sort_unstable();
820 let count = paths.len();
821 paths.dedup();
822 assert_eq!(paths.len(), count, "a path is declared twice");
823 }
824
825 #[test]
826 fn the_rendering_names_every_declared_path_and_the_placeholder_rule() {
827 let writes = runtime_writes(EnforcementTier::KernelSealed);
828 let rendered = render_runtime_writes(&writes);
829 assert!(rendered.contains("`<session-id>` is a literal segment"));
830 for write in &writes {
831 assert!(
832 rendered.contains(&write.path),
833 "{} is missing from the rendering",
834 write.path
835 );
836 }
837 assert!(rendered.ends_with('\n'));
838 }
839}