Skip to main content

mur_common/config/
ops.rs

1use super::*;
2
3/// Pre-dispatch triage settings (`triage:` in `~/.mur/config.yaml`).
4///
5/// Both knobs default to the passive posture, and `enforce` in particular is
6/// off for a reason that is not caution: `triage_calibration` cannot score a
7/// decision that prevented a run. Enforcing from the start produces only
8/// unfalsifiable `held_back` records, so there would never be evidence that
9/// enforcing was correct. Shadow mode records the same predictions AND lets
10/// the run happen, which is what makes `Calibration::shadow_precision`
11/// computable — turn `enforce` on once that number says triage is right often
12/// enough to be worth the refusals.
13#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
14pub struct TriageConfig {
15    /// Run triage before dispatch at all. Off = not even the free prefilter
16    /// runs, and no verdicts are recorded.
17    #[serde(default)]
18    pub enabled: bool,
19    /// Let a triage decision actually stop a dispatch. Requires `enabled`.
20    /// See the type docs before turning this on.
21    #[serde(default)]
22    pub enforce: bool,
23}
24
25impl TriageConfig {
26    /// Does triage actually bind here? `enforce` alone is inert — with
27    /// `enabled: false` nothing runs to enforce — so the two are resolved in
28    /// one place rather than at each call site, where the pair would
29    /// eventually be got wrong in one of them.
30    pub fn enforces(&self) -> bool {
31        self.enabled && self.enforce
32    }
33}
34
35/// Rotation for `~/.mur/queue/events.jsonl`, in the shape FreeBSD's
36/// `newsyslog(8)` uses: rotate past a size, keep a bounded number of
37/// generations, compress all but the newest, drop the oldest.
38///
39/// The point of generations is that nobody has to decide to delete anything.
40/// A 934 MB queue becomes `.0`, then `.1.gz`, and ages out on a policy the
41/// user set rather than on a judgement call someone makes once.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct CaptureConfig {
44    /// Rotate once the live file passes this. 64 MB keeps `mur hook stats`
45    /// responsive — parsing is O(file), and 934 MB took minutes.
46    #[serde(default = "default_rotate_at_mb")]
47    pub rotate_at_mb: u64,
48    /// How many rotated generations to keep. `.0` stays uncompressed like
49    /// newsyslog's, the rest are gzipped.
50    #[serde(default = "default_keep_generations")]
51    pub keep_generations: u32,
52}
53
54pub(super) fn default_rotate_at_mb() -> u64 {
55    64
56}
57
58fn default_keep_generations() -> u32 {
59    5
60}
61
62impl Default for CaptureConfig {
63    fn default() -> Self {
64        Self {
65            rotate_at_mb: default_rotate_at_mb(),
66            keep_generations: default_keep_generations(),
67        }
68    }
69}
70
71/// Post-upgrade settings for `mur update`. Stored under `update:` in
72/// `~/.mur/config.yaml`.
73#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
74pub struct UpdateConfig {
75    /// macOS code-signing identity used to re-sign installed binaries after an
76    /// upgrade. Keychain grants bind to the signing identity; fresh installs
77    /// are ad-hoc (new CDHash per build), so without a stable identity every
78    /// upgrade kills the grants and service-launched agents fail silently
79    /// (#849/#866). Fallback: the `MUR_CODESIGN_IDENTITY` env var.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub codesign_identity: Option<String>,
82    /// Agent names `mur update --restart-agents` must never touch.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub restart_exclude: Vec<String>,
85}
86
87/// Which notification channels the durable monitor may use. `log` is not
88/// listed: it is always on and cannot be disabled, because a notable event
89/// must leave a trace somewhere even when a user has turned everything off.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct NotificationsConfig {
92    /// OS desktop notification. Opt-in: a background daemon that starts
93    /// popping banners on upgrade is a hostile default.
94    #[serde(default)]
95    pub desktop: bool,
96}
97
98/// Whether the durable monitor may ask a model what to do about a terminal
99/// failure the structured rules did not settle.
100///
101/// Off by default, and not merely as a courtesy: enabling it lets a
102/// background daemon send monitor context to a model on its own schedule,
103/// with no one watching. Nobody watching is not permission, so this is a
104/// decision a user makes once, explicitly, rather than something an upgrade
105/// makes for them.
106///
107/// The bound on how often it may ask is NOT here: one proposal per
108/// observation cycle is an invariant of the design, not a knob (see
109/// `mur_core::monitor::resolver`). A tunable would let a user turn a bounded
110/// feature into an unbounded one.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct MonitorResolverConfig {
113    /// Opt-in. While false, nothing in the resolver path runs and no request
114    /// leaves the machine.
115    #[serde(default)]
116    pub enabled: bool,
117
118    /// Which model to ask. `None` uses the same backend `mur chat` resolves,
119    /// so a user who has already configured one does not configure it twice.
120    #[serde(default)]
121    pub model: Option<String>,
122}
123
124/// Run-status heartbeat tuning. Both values are config, never literals at a
125/// call site: the right interval depends on how long the machine's steps take.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RunsConfig {
128    /// How often `execute_dag` stamps `last_heartbeat_at`.
129    #[serde(default = "default_heartbeat_interval_secs")]
130    pub heartbeat_interval_secs: u64,
131    /// How many missed intervals before a live process counts as `stalled`.
132    /// Three tolerates one lost tick plus scheduling jitter without calling a
133    /// healthy run dead.
134    #[serde(default = "default_heartbeat_stale_after_intervals")]
135    pub heartbeat_stale_after_intervals: u32,
136}
137
138pub(super) fn default_heartbeat_interval_secs() -> u64 {
139    10
140}
141
142pub(super) fn default_heartbeat_stale_after_intervals() -> u32 {
143    3
144}
145
146impl Default for RunsConfig {
147    fn default() -> Self {
148        Self {
149            heartbeat_interval_secs: default_heartbeat_interval_secs(),
150            heartbeat_stale_after_intervals: default_heartbeat_stale_after_intervals(),
151        }
152    }
153}
154
155/// Authorization gate for the `parallel_jobs` MCP tool. Stored under `parallel_jobs:`
156/// in `~/.mur/config.yaml`. Deny-by-default: an empty `targets` list means the
157/// tool cannot delegate to ANY agent (inert until the user opts specific
158/// agents in). This is a deterministic, out-of-model gate that a
159/// prompt-injected concierge cannot widen (OWASP Agentic ASI02/03/04).
160#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
161pub struct ParallelJobsConfig {
162    /// Canonical agent names the `parallel_jobs` tool is allowed to delegate to.
163    /// Empty = deny all.
164    #[serde(default)]
165    pub targets: Vec<String>,
166}
167
168// --- memory-federation snapshot (spec 2026-08-04-unified-memory-federation) ---
169
170/// Daemon-side settings for the signed snapshot pull. Stored under
171/// `federation_snapshot:` in `~/.mur/config.yaml`; every field has a default
172/// so an absent block means "defaults", never "off".
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174#[serde(default)]
175pub struct SnapshotConfig {
176    /// How often the daemon sweeps `inbox/snapshot-requests/`, in seconds.
177    pub poll_secs: u64,
178    /// Reject requests older than this (replay blunting), in seconds.
179    pub request_max_age_secs: u64,
180    /// Minimum lifecycle state a global skill needs to enter a snapshot.
181    pub min_lifecycle: crate::skill::stats::LifecycleState,
182}
183
184impl Default for SnapshotConfig {
185    fn default() -> Self {
186        Self {
187            poll_secs: 30,
188            request_max_age_secs: 600,
189            min_lifecycle: crate::skill::stats::LifecycleState::Stable,
190        }
191    }
192}
193
194/// Proactive memory capture (memory federation P2): gates the runtime's
195/// built-in `remember` tool and its system-prompt directive. Stored under
196/// `memory:` in `~/.mur/config.yaml`.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198#[serde(default)]
199pub struct MemoryConfig {
200    pub capture: CaptureMode,
201    /// How many memories may enter the system prompt per turn. Deliberately
202    /// NOT `skills.max_skills_in_prompt`: sharing that budget means saving a
203    /// memory silently evicts a skill, and five is a plausible number of
204    /// standing preferences for one user to have.
205    #[serde(default = "default_memory_in_prompt")]
206    pub max_in_prompt: usize,
207    /// Character ceiling for the whole memory block, spent before skills.
208    #[serde(default = "default_memory_chars")]
209    pub max_chars: usize,
210}
211
212fn default_memory_in_prompt() -> usize {
213    20
214}
215
216fn default_memory_chars() -> usize {
217    1500
218}
219
220impl Default for MemoryConfig {
221    fn default() -> Self {
222        Self {
223            capture: CaptureMode::AutoAnnounce,
224            max_in_prompt: default_memory_in_prompt(),
225            max_chars: default_memory_chars(),
226        }
227    }
228}
229
230/// How agents capture memories mid-conversation.
231#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "snake_case")]
233pub enum CaptureMode {
234    /// Agent asks for a one-line confirmation before saving.
235    Ask,
236    /// Agent saves immediately and announces in the same reply (default).
237    AutoAnnounce,
238    /// The `remember` tool is not registered at all.
239    Off,
240}
241
242/// Authorization gate for the runtime's built-in `fleet_run` tool. Stored under
243/// `fleet_run:` in `~/.mur/config.yaml`. Deny-by-default on BOTH axes: an agent
244/// not named in `agents` never even sees the tool, and a fleet not named in
245/// `fleets` cannot be run. Lives in the global config (not the agent profile)
246/// because the profile is writable by the concierge itself — this gate must be
247/// out of reach of a prompt-injected agent (same rationale as
248/// [`ParallelJobsConfig`]).
249#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
250pub struct FleetRunConfig {
251    /// Canonical agent names allowed to call `fleet_run`. Empty = deny all.
252    #[serde(default)]
253    pub agents: Vec<String>,
254    /// Fleet names those agents may run. Empty = deny all.
255    #[serde(default)]
256    pub fleets: Vec<String>,
257}
258
259/// Gate for the git push broker (`git_push:` in `~/.mur/config.yaml`).
260/// Deny-by-default on BOTH axes, like [`FleetRunConfig`]: `enabled` is the
261/// broker-wide switch (off: the daemon sweeper does not run and no agent gets
262/// anything), and `agents` names the only agents that see `git_push_request` /
263/// `git_push_status` / `git_push_cancel` and get the Landlock read grant on the
264/// registry. An agent not named here — a deep-research worker, say — never sees
265/// the tools, so it neither learns the registry exists nor adds requests to the
266/// human approval queue. Global config, not the agent profile, for the same
267/// reason as [`FleetRunConfig`]: a prompt-injected agent must not widen it.
268#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
269pub struct GitPushConfig {
270    #[serde(default)]
271    pub enabled: bool,
272    /// Canonical agent names allowed the push tools. Empty = deny all.
273    #[serde(default)]
274    pub agents: Vec<String>,
275}
276
277/// Display policy for `mur open`.
278///
279/// Lives in `config.yaml` rather than in `open-items.jsonl` because that log
280/// is append-only and agent-writable via the `open_item` tool. A user's
281/// decision to stop looking at a source must not be overturnable by an agent
282/// appending a record. Same reasoning as `fleet_run.agents`.
283#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
284pub struct OpenItemsConfig {
285    /// Exact `origin` strings to collapse out of `mur open`. Exact match
286    /// only — `fleet` never matches `fleet:acme`.
287    #[serde(default)]
288    pub muted: Vec<String>,
289}
290
291/// Daemon-wide gate for unattended fleet auto-run (`mur-daemon`'s `fleet_tick`).
292/// Stored under `fleet:` in `~/.mur/config.yaml`. Either this flag OR the
293/// `MUR_FLEET_AUTORUN` env var satisfies the gate — both are equally explicit,
294/// off-by-default opt-ins; the env var remains for ops/CI use, this flag is
295/// what the Hub's Settings toggle controls. Per-fleet `budget_usd > 0` and the
296/// `.stopped` kill-switch are unaffected — see `mur-daemon/src/fleet_tick.rs`.
297#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
298pub struct FleetConfig {
299    /// Allow fleets with a trigger + budget configured to auto-run unattended.
300    #[serde(default)]
301    pub autorun: bool,
302}
303
304/// Routing for Anthropic subscription-OAuth (`sk-ant-oat*`) tokens through a
305/// local bridge — cc-proxy — that swaps `x-api-key` for the Bearer +
306/// claude-code betas disguise the upstream requires.
307///
308/// The Hub injects `ANTHROPIC_BASE_URL` pointing at [`url`](Self::url) when it
309/// spawns an agent runtime, but only when [`enabled`](Self::enabled) is set and
310/// the bridge is actually listening; otherwise it leaves the runtime on the
311/// direct `api.anthropic.com` path (where an oat token would 401). A runtime
312/// launched with `ANTHROPIC_BASE_URL` already in its environment is never
313/// overridden.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct CcProxyConfig {
316    /// Bridge base URL. Defaults to cc-proxy's default bind.
317    #[serde(default = "default_cc_proxy_url")]
318    pub url: String,
319
320    /// Master switch. When false the Hub never routes runtimes through the
321    /// bridge, regardless of reachability.
322    #[serde(default = "default_true")]
323    pub enabled: bool,
324}
325
326fn default_cc_proxy_url() -> String {
327    "http://127.0.0.1:8088".to_string()
328}
329
330fn default_true() -> bool {
331    true
332}
333
334impl Default for CcProxyConfig {
335    fn default() -> Self {
336        Self {
337            url: default_cc_proxy_url(),
338            enabled: true,
339        }
340    }
341}
342
343/// Configuration for the agent CLI TUI.
344/// Stored in ~/.mur/config.yaml under the `cli:` key.
345#[derive(Debug, Clone, Serialize, Deserialize, Default)]
346pub struct CliConfig {
347    /// Default visual skin for `mur agent cli`. Overridable with --skin.
348    /// Valid values: "ansi" (default; "dark" is an alias), "light", "mur".
349    pub skin: Option<String>,
350
351    /// True once the one-time "permanent instructions" notice has been shown
352    /// (memories P1 §10).
353    ///
354    /// The notice is informational and must appear at most once — repeating a
355    /// feature announcement every session reads as a warning about something
356    /// wrong. Absent (false) in existing configs, which is correct: a user who
357    /// has never seen it should.
358    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
359    pub seen_permanent_instructions_notice: bool,
360}
361
362/// Configuration for the mobile relay (P4).
363/// Stored in ~/.mur/config.yaml under the `mobile_relay:` key.
364#[derive(Debug, Clone, Serialize, Deserialize, Default)]
365pub struct MobileRelayConfig {
366    /// Base URL of the mur-server relay, e.g. "wss://relay.mur.run".
367    /// Leave blank to disable relay forwarding on the Mac daemon side.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub relay_url: Option<String>,
370
371    /// API key or JWT used by the Mac daemon to authenticate with the relay.
372    /// The value is typically a `mur_...` API key from app.mur.run.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub api_key: Option<String>,
375}
376
377#[cfg(test)]
378mod fleet_config_tests {
379    use super::*;
380
381    #[test]
382    fn fleet_config_defaults_off_and_roundtrips() {
383        assert!(!FleetConfig::default().autorun);
384
385        let cfg: Config = serde_yaml_ng::from_str("fleet:\n  autorun: true\n").unwrap();
386        assert!(cfg.fleet.autorun);
387
388        // `fleet:` key entirely absent → defaults to off
389        let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
390        assert!(!cfg2.fleet.autorun);
391    }
392
393    #[test]
394    fn fleet_run_config_defaults_deny_all_and_roundtrips() {
395        // Absent section → both allowlists empty → deny all.
396        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
397        assert!(cfg.fleet_run.agents.is_empty());
398        assert!(cfg.fleet_run.fleets.is_empty());
399
400        let cfg2: Config =
401            serde_yaml_ng::from_str("fleet_run:\n  agents: [mur]\n  fleets: [deep-research]\n")
402                .unwrap();
403        assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
404        assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
405    }
406
407    #[test]
408    fn git_push_config_defaults_off_and_roundtrips() {
409        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
410        assert!(!cfg.git_push.enabled);
411        assert!(cfg.git_push.agents.is_empty());
412        let cfg2: Config =
413            serde_yaml_ng::from_str("git_push:\n  enabled: true\n  agents: [mur]\n").unwrap();
414        assert!(cfg2.git_push.enabled);
415        assert_eq!(cfg2.git_push.agents, vec!["mur"]);
416    }
417}
418
419#[cfg(test)]
420mod runs_config_tests {
421    use super::*;
422
423    /// A zero `heartbeat_interval_secs` is legal YAML but illegal at runtime:
424    /// it zeroes the stale threshold (every live run instantly reads STALLED)
425    /// and `tokio::time::interval(Duration::ZERO)` panics in the executor's
426    /// ticker. The loader must clamp zeroes to the defaults so one
427    /// user-edited line can neither lie nor crash.
428    #[test]
429    fn zero_heartbeat_values_clamp_to_defaults_at_load() {
430        let tmp = tempfile::tempdir().unwrap();
431        let path = tmp.path().join("config.yaml");
432        std::fs::write(
433            &path,
434            "runs:\n  heartbeat_interval_secs: 0\n  heartbeat_stale_after_intervals: 0\n",
435        )
436        .unwrap();
437
438        let cfg = Config::load_or_default(&path);
439        assert_eq!(
440            cfg.runs.heartbeat_interval_secs, 10,
441            "a zero interval must load as the default, not 0"
442        );
443        assert_eq!(
444            cfg.runs.heartbeat_stale_after_intervals, 3,
445            "a zero interval count must load as the default, not 0"
446        );
447    }
448
449    /// The clamp must not rewrite legitimate tuning: positive values survive.
450    #[test]
451    fn positive_heartbeat_values_survive_the_load() {
452        let tmp = tempfile::tempdir().unwrap();
453        let path = tmp.path().join("config.yaml");
454        std::fs::write(
455            &path,
456            "runs:\n  heartbeat_interval_secs: 60\n  heartbeat_stale_after_intervals: 2\n",
457        )
458        .unwrap();
459
460        let cfg = Config::load_or_default(&path);
461        assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
462        assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
463    }
464}
465
466#[cfg(test)]
467mod ambient_capture_cfg_tests {
468    use super::*;
469
470    #[test]
471    fn session_and_harvest_defaults() {
472        let cfg: Config = serde_yaml::from_str("{}").unwrap();
473        assert_eq!(cfg.session.capture, "ambient");
474        assert_eq!(cfg.session.retention_days, 14);
475        assert!(cfg.harvest.auto_gate);
476        assert_eq!(cfg.harvest.llm, "local-first");
477        assert_eq!(cfg.harvest.min_events, 5);
478        assert_eq!(cfg.harvest.min_user_turns, 2);
479        assert_eq!(cfg.harvest.min_duration_secs, 120);
480        assert_eq!(cfg.harvest.idle_minutes, 30);
481        assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
482        assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
483        assert!(cfg.harvest.session_start_hint);
484        assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
485    }
486
487    #[test]
488    fn session_capture_override_parses() {
489        let cfg: Config =
490            serde_yaml::from_str("session:\n  capture: off\n  retention_days: 3\n").unwrap();
491        assert_eq!(cfg.session.capture, "off");
492        assert_eq!(cfg.session.retention_days, 3);
493    }
494}
495
496#[cfg(test)]
497mod cc_proxy_cfg_tests {
498    use super::*;
499
500    #[test]
501    fn defaults_to_local_cc_proxy_enabled() {
502        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
503        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
504        assert!(cfg.cc_proxy.enabled);
505    }
506
507    #[test]
508    fn url_and_enabled_override_parse() {
509        let cfg: Config =
510            serde_yaml_ng::from_str("cc_proxy:\n  url: http://127.0.0.1:9999\n  enabled: false\n")
511                .unwrap();
512        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
513        assert!(!cfg.cc_proxy.enabled);
514    }
515
516    #[test]
517    fn partial_section_keeps_other_default() {
518        // Only `enabled` given → url stays at the default.
519        let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n  enabled: false\n").unwrap();
520        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
521        assert!(!cfg.cc_proxy.enabled);
522    }
523}
524
525#[cfg(test)]
526mod notifications_config_tests {
527    use super::*;
528
529    #[test]
530    fn notifications_default_to_log_only() {
531        let c: Config = serde_yaml::from_str("{}").unwrap();
532        assert!(!c.notifications.desktop, "desktop must be opt-in");
533    }
534
535    #[test]
536    fn an_existing_config_without_the_block_still_parses() {
537        // Every user upgrading has a config.yaml with no `notifications:` key.
538        let c: Config = serde_yaml::from_str("retrieval:\n  min_score: 0.42\n").unwrap();
539        assert!(!c.notifications.desktop);
540    }
541
542    /// The whole safety argument for the resolver rests on this one bit: an
543    /// upgrade must never start letting the daemon talk to a model. Asserted
544    /// from both an empty config and a realistic existing one, because the
545    /// failure that matters is an upgrade, not a fresh install.
546    #[test]
547    fn the_monitor_resolver_is_off_until_a_user_turns_it_on() {
548        let empty: Config = serde_yaml::from_str("{}").unwrap();
549        assert!(
550            !empty.monitor_resolver.enabled,
551            "asking a model must be opt-in"
552        );
553        assert!(empty.monitor_resolver.model.is_none());
554
555        let upgraded: Config = serde_yaml::from_str(
556            "retrieval:\n  min_score: 0.42\nnotifications:\n  desktop: true\n",
557        )
558        .unwrap();
559        assert!(
560            !upgraded.monitor_resolver.enabled,
561            "a config written before this feature existed must not enable it"
562        );
563    }
564
565    #[test]
566    fn the_monitor_resolver_reads_back_what_a_user_wrote() {
567        let c: Config =
568            serde_yaml::from_str("monitor_resolver:\n  enabled: true\n  model: claude_haiku\n")
569                .unwrap();
570        assert!(c.monitor_resolver.enabled);
571        assert_eq!(c.monitor_resolver.model.as_deref(), Some("claude_haiku"));
572    }
573
574    /// Triage must be inert for everyone who never asked for it — including
575    /// every config file written before it existed.
576    #[test]
577    fn triage_is_off_and_non_binding_by_default() {
578        let c: Config = serde_yaml::from_str("retrieval:\n  min_score: 0.42\n").unwrap();
579        assert!(!c.triage.enabled, "triage must not run unasked");
580        assert!(!c.triage.enforce);
581        assert!(!c.triage.enforces());
582    }
583
584    /// The trap this guards: `enforce: true` alone looks like it turned
585    /// something on, but there is nothing running to enforce.
586    #[test]
587    fn enforce_without_enabled_binds_nothing() {
588        let c: Config = serde_yaml::from_str("triage:\n  enforce: true\n").unwrap();
589        assert!(c.triage.enforce, "the user's word is kept verbatim");
590        assert!(
591            !c.triage.enforces(),
592            "but it binds nothing while triage does not run"
593        );
594    }
595
596    #[test]
597    fn triage_binds_only_when_both_knobs_are_set() {
598        let c: Config =
599            serde_yaml::from_str("triage:\n  enabled: true\n  enforce: true\n").unwrap();
600        assert!(c.triage.enforces());
601    }
602
603    /// Enabled without enforce is shadow mode: it runs, it records, it never
604    /// stops anything.
605    #[test]
606    fn enabled_alone_is_shadow_mode() {
607        let c: Config = serde_yaml::from_str("triage:\n  enabled: true\n").unwrap();
608        assert!(c.triage.enabled);
609        assert!(!c.triage.enforces());
610    }
611}