mur-common 2.94.0

Shared types and traits for the MUR ecosystem
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
use super::*;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Entitlements {
    pub network: NetworkEntitlement,
    pub filesystem: FilesystemEntitlement,
    pub processes: ProcessesEntitlement,
    #[serde(default)]
    pub syscalls: SyscallsEntitlement,
    #[serde(default)]
    pub limits: LimitsEntitlement,
    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
    /// so the supervisor refuses to construct an LLM client.
    #[serde(default)]
    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
    /// Per-tool allow/ask/deny policy. Empty = all tools use default (Ask).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tools: Vec<ToolRule>,
    /// When `true` (the default), a sandbox apply failure is fatal: the agent
    /// refuses to start rather than running advisory-only (unconfined).
    /// Set to `false` only for development or trusted-workstation agents that
    /// intentionally run without kernel sandbox enforcement.
    #[serde(default = "default_true")]
    pub fail_closed_on_sandbox_error: bool,
    /// SSH agent proxy policy. Lives under `entitlements` so the entitlement
    /// pin covers it — on Linux the pin is the only tamper protection.
    #[serde(default, skip_serializing_if = "SshEntitlement::is_empty")]
    pub ssh: SshEntitlement,
}

/// Default-deny allowlist of key fingerprints and, per key, host-key
/// fingerprints. Fingerprints are the only identities; labels are display only.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SshEntitlement {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keys: Vec<SshKeyRule>,
}

impl SshEntitlement {
    pub fn is_empty(&self) -> bool {
        self.keys.is_empty()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SshKeyRule {
    pub fingerprint: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allow_hosts: Vec<SshHostRule>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SshHostRule {
    pub host_key: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkEntitlement {
    pub inbound: InboundNetwork,
    pub outbound: OutboundNetwork,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct InboundNetwork {
    #[serde(default)]
    pub ports: Vec<u16>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OutboundNetwork {
    pub mode: NetworkOutboundMode,
    #[serde(default)]
    pub allow_hosts: Vec<String>,
    /// Extra outbound TCP ports granted on top of the built-in web set
    /// (`RESTRICTED_GENERAL_PORTS`: 80/443/8080/8443). Issue #006: without
    /// this, a non-web port (ssh 2222, vite 5173, ollama 11434) was
    /// unreachable under `restricted` and the only escape was
    /// `unrestricted`, which opens EVERY port.
    ///
    /// Honored under `Restricted` ONLY. `Off` stays air-gapped and
    /// `ProxyOnly` keeps denying general TCP — a stale entry in a profile
    /// whose mode was later tightened must never silently reopen it.
    ///
    /// This is a PORT grant, not a host grant: like the base set, the port
    /// opens to host `*`, because macOS SBPL's `remote tcp` accepts only
    /// `*` or `localhost` as the host. Bounding WHICH host is reached on
    /// that port remains HostGuard's job via `allow_hosts`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allow_ports: Vec<u16>,
    #[serde(default = "default_protocols")]
    pub protocols: Vec<String>,
    #[serde(default)]
    pub resolve_dns: ResolveDnsConfig,
}
fn default_protocols() -> Vec<String> {
    vec!["tcp".to_string()]
}

/// Record of who authorized a broad egress grant, and when. Attached to a
/// per-MCP-server `McpServerNetwork` when its mode is `BroadAudited`, so the
/// grant is persisted, portable, and re-approvable on import.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EgressAuthorization {
    pub authorized_by: String,
    pub authorized_at_ms: u64,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NetworkOutboundMode {
    Unrestricted,
    Restricted,
    /// Deny all general outbound TCP; egress is ONLY via loopback proxies
    /// (the agent's cc-proxy LLM port + the egress proxy). Hostnames are still
    /// governed by `allow_hosts` (HostGuard) — unlike `Off`, which blocks all.
    ProxyOnly,
    Off,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolveDnsConfig {
    #[serde(default = "default_dns_mode")]
    pub mode: String,
    #[serde(default)]
    pub servers: Vec<String>,
}
impl Default for ResolveDnsConfig {
    fn default() -> Self {
        Self {
            mode: default_dns_mode(),
            servers: vec![],
        }
    }
}
fn default_dns_mode() -> String {
    "system".to_string()
}

/// Dirs under `<mur_home>` where MUR objects are authored.
///
/// The seeded concierge gets read+write on these; without them the one agent a
/// fresh host has can describe a skill or workflow but cannot create one, and
/// every answer ends in "run this command yourself".
///
/// Deliberately excludes `agents/`: `self_protected()` only covers an agent's
/// OWN `profile.yaml` + `identity.key`, so write access there would let an
/// agent author a sibling with unrestricted entitlements and start it, and
/// read access would expose every other agent's Ed25519 signing key.
///
/// Deliberately excludes [`crate::paths::FLEETS`] for the same reason one
/// level up: `fleet.yaml` names a fleet's members, limits and HITL
/// pre-approvals, and `.stopped` is the operator's kill-switch. An agent that
/// can write there can widen what a `fleet_run` it triggers is allowed to do,
/// or clear the stop on it. Fleets are created with `mur fleet create`; the
/// runtime already reads `fleets/` on its own (sandbox policy), so dropping
/// the grant costs the concierge nothing it needs to *use* a fleet.
pub const AUTHORING_DIRS: [&str; 3] = ["skills", "workflows", "artifacts"];

/// The user's own credential directories, denied in every profile MUR writes.
///
/// SSH keys, cloud credentials and the GPG keyring: an agent has no reason to
/// read any of them, and holding one is acting as the user on every host it
/// unlocks. The agent wizard presets and the seeded concierge template both
/// start from this list, so a new agent never ships with an empty deny.
///
/// `~`-relative on purpose — the CLI expands it at write time, and this list
/// must mean the same thing on every install.
pub const DEFAULT_DENY_PATHS: [&str; 3] = ["~/.ssh", "~/.aws", "~/.gnupg"];

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FilesystemEntitlement {
    #[serde(default)]
    pub read: Vec<String>,
    #[serde(default)]
    pub write: Vec<String>,
    #[serde(default)]
    pub deny: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProcessesEntitlement {
    pub spawn: SpawnEntitlement,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpawnEntitlement {
    pub mode: SpawnMode,
    #[serde(default)]
    pub allowed: Vec<String>,
    /// Directories whose entire subtree may be exec'd — the "build lane".
    ///
    /// `allowed` cannot express a toolchain that compiles its own
    /// executables: a Rust build execs `target/debug/build/<crate>-<hash>/
    /// build-script-build`, proc-macro shims, and freshly linked test
    /// binaries, all at paths that do not exist until the build creates them
    /// and change on every dependency bump. Without this an agent granted
    /// `cargo` could compile nothing and could never verify its own work.
    ///
    /// Grant narrowly — a build-output directory, not a source tree or a
    /// home directory. Everything under it becomes exec'able, so the tree
    /// should be one the agent already has write access to and nothing else
    /// depends on. Filesystem and network entitlements still bound what the
    /// executed code can reach.
    #[serde(default)]
    pub allowed_dirs: Vec<String>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SpawnMode {
    Allowlist,
    Any,
    None,
    /// Shell-only: fences the system exec paths (`/bin`, `/usr/bin`,
    /// `/usr/lib`) that `Allowlist` mode exempts by default, so only the
    /// resolved shell binary the `bash` tool itself spawns plus the
    /// profile's own `spawn_allowed_paths`/`spawn_allowed_prefixes` may be
    /// exec'd -- no other system binary (coreutils, `git`, etc.) is implied.
    Strict,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SyscallsEntitlement {
    #[serde(default = "default_syscalls_mode")]
    pub mode: String,
    #[serde(default)]
    pub extra_deny: Vec<String>,
}
fn default_syscalls_mode() -> String {
    "default".to_string()
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LimitsEntitlement {
    #[serde(default)]
    pub cpu_seconds: Option<u64>,
    #[serde(default = "default_memory_mb")]
    pub memory_mb: u64,
    #[serde(default = "default_fds")]
    pub file_descriptors: u32,
    #[serde(default = "default_procs")]
    pub processes: u32,
}
fn default_memory_mb() -> u64 {
    512
}
fn default_fds() -> u32 {
    1024
}
fn default_procs() -> u32 {
    32
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolPolicy {
    Allow,
    #[default]
    Ask,
    Deny,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolRule {
    pub pattern: String,
    pub policy: ToolPolicy,
    /// Intrinsic risk tier of this tool (v3c). Resolved most-restrictive-wins
    /// across every matching rule (see [`resolve_tool_risk`]); a tier above
    /// `Write` turns `policy: allow` into a pre-execution approval (#1600).
    /// `deny` still wins outright.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub risk: Option<crate::hitl::RiskTier>,
    /// #1786: opt this one rule out of the built-in tier floor (acting
    /// browser tools ask even under a riskless `allow`). Default off; it never
    /// lowers a declared `risk:` — that field only ever raises. Honored only
    /// on an exact-name rule; on a glob it is ignored (`perm list-tools` and
    /// `doctor` say so). The runtime logs every call where it waives a floor.
    /// #1814: may also name a tier ("waive up to this tier"); on `bash` only
    /// that form counts — see [`crate::hitl::floor_waiver`].
    #[serde(
        default,
        skip_serializing_if = "crate::hitl::floor_waiver::FloorWaiver::is_off"
    )]
    pub bypass_builtin_tier_floor: crate::hitl::floor_waiver::FloorWaiver,
}

/// Resolve the effective policy for `tool_name` against an ordered rule list.
///
/// Precedence: exact-name match > longest-prefix glob (trailing `*`) > default (`Ask`).
pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
    resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
}

/// Like [`resolve_tool_policy`] but distinguishes "no rule matched" (`None`)
/// from an explicit rule — for tools whose registration is already gated
/// elsewhere (e.g. `fleet_run`'s config allowlist) and that therefore want a
/// different default than `Ask` while still honoring explicit rules.
pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
    resolve_tool_rule(rules, tool_name).map(|rule| rule.policy)
}

/// The single rule that decides `tool_name`'s policy (same precedence as
/// [`resolve_tool_policy_opt`]); `None` = no rule matched. Per-rule switches
/// such as `bypass_builtin_tier_floor` are read from this rule only.
pub fn resolve_tool_rule<'a>(rules: &'a [ToolRule], tool_name: &str) -> Option<&'a ToolRule> {
    for rule in rules {
        if rule.pattern == tool_name {
            return Some(rule);
        }
    }
    let mut best: Option<(&ToolRule, usize)> = None;
    for rule in rules {
        if let Some(prefix) = rule.pattern.strip_suffix('*')
            && tool_name.starts_with(prefix)
        {
            let len = prefix.len();
            if best.is_none_or(|(_, best_len)| len > best_len) {
                best = Some((rule, len));
            }
        }
    }
    best.map(|(rule, _)| rule)
}

/// The declared `risk:` that applies to `tool_name` — the STRICTEST across
/// every matching rule (exact or trailing-`*` glob), so the field comment's
/// most-restrictive-wins holds even when a narrower rule decided the policy.
/// `None` = no matching rule declares a risk; the caller's own classification
/// stands alone.
pub fn resolve_tool_risk(rules: &[ToolRule], tool_name: &str) -> Option<crate::hitl::RiskTier> {
    rules
        .iter()
        .filter(|rule| {
            rule.pattern == tool_name
                || rule
                    .pattern
                    .strip_suffix('*')
                    .is_some_and(|prefix| tool_name.starts_with(prefix))
        })
        .filter_map(|rule| rule.risk)
        .max()
}

#[cfg(test)]
mod tool_policy_tests {
    use super::*;

    fn rules() -> Vec<ToolRule> {
        vec![
            ToolRule {
                pattern: "mcp__github__merge_pr".into(),
                policy: ToolPolicy::Ask,
                risk: None,
                bypass_builtin_tier_floor: Default::default(),
            },
            ToolRule {
                pattern: "mcp__github__*".into(),
                policy: ToolPolicy::Allow,
                risk: None,
                bypass_builtin_tier_floor: Default::default(),
            },
            ToolRule {
                pattern: "mcp__*".into(),
                policy: ToolPolicy::Deny,
                risk: None,
                bypass_builtin_tier_floor: Default::default(),
            },
            ToolRule {
                pattern: "bash".into(),
                policy: ToolPolicy::Allow,
                risk: None,
                bypass_builtin_tier_floor: Default::default(),
            },
        ]
    }

    fn risky(pattern: &str, policy: ToolPolicy, risk: RiskTier) -> ToolRule {
        ToolRule {
            pattern: pattern.into(),
            policy,
            risk: Some(risk),
            bypass_builtin_tier_floor: Default::default(),
        }
    }

    use crate::hitl::RiskTier;

    /// #1600: most-restrictive-wins across EVERY matching rule, not just the
    /// one whose policy won — a narrow `allow` must not shed a broad rule's
    /// `destructive`.
    #[test]
    fn tool_risk_is_the_strictest_matching_rule() {
        let rules = vec![
            risky("mcp__browser__*", ToolPolicy::Allow, RiskTier::Destructive),
            risky("mcp__browser__click", ToolPolicy::Allow, RiskTier::Write),
            risky("mcp__other__*", ToolPolicy::Allow, RiskTier::Privileged),
        ];
        assert_eq!(
            resolve_tool_risk(&rules, "mcp__browser__click"),
            Some(RiskTier::Destructive)
        );
        assert_eq!(
            resolve_tool_risk(&rules, "mcp__browser__navigate"),
            Some(RiskTier::Destructive)
        );
    }

    #[test]
    fn tool_risk_is_none_without_a_declaring_rule() {
        assert_eq!(resolve_tool_risk(&rules(), "mcp__github__merge_pr"), None);
        assert_eq!(resolve_tool_risk(&[], "bash"), None);
    }

    #[test]
    fn exact_beats_glob() {
        assert_eq!(
            resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
            ToolPolicy::Ask
        );
    }

    #[test]
    fn longer_glob_wins() {
        assert_eq!(
            resolve_tool_policy(&rules(), "mcp__github__create_issue"),
            ToolPolicy::Allow
        );
    }

    #[test]
    fn shorter_glob_fallback() {
        assert_eq!(
            resolve_tool_policy(&rules(), "mcp__slack__send"),
            ToolPolicy::Deny
        );
    }

    #[test]
    fn exact_bash() {
        assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
    }

    #[test]
    fn unknown_tool_defaults_ask() {
        assert_eq!(
            resolve_tool_policy(&rules(), "unknown_tool"),
            ToolPolicy::Ask
        );
    }

    #[test]
    fn empty_rules_defaults_ask() {
        assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
    }

    fn minimal_entitlements_yaml() -> &'static str {
        "network:\n  inbound: {}\n  outbound:\n    mode: off\nfilesystem: {}\nprocesses:\n  spawn:\n    mode: none\n"
    }

    #[test]
    fn entitlements_tools_defaults_empty() {
        let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
        assert!(e.tools.is_empty());
    }

    #[test]
    fn entitlements_tools_roundtrip() {
        let base = minimal_entitlements_yaml();
        let yaml = format!("{base}tools:\n  - pattern: \"mcp__github__*\"\n    policy: allow\n");
        let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
        assert_eq!(e.tools.len(), 1);
        assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
        let y = serde_yaml_ng::to_string(&e).unwrap();
        let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
        assert_eq!(back.tools.len(), 1);
        assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
    }
    #[test]
    fn denylist_membership_and_mutation() {
        let mut list: Vec<String> = vec![];
        assert!(name_enabled(&list, "a"), "empty denylist => enabled");

        set_denylist(&mut list, "a", false); // disable
        assert!(!name_enabled(&list, "a"));
        assert_eq!(list, ["a"]);

        set_denylist(&mut list, "a", false); // idempotent disable
        assert_eq!(list, ["a"], "no duplicate entries");

        set_denylist(&mut list, "a", true); // enable removes
        assert!(name_enabled(&list, "a"));
        assert!(list.is_empty());

        set_denylist(&mut list, "b", true); // enabling an absent name is a no-op
        assert!(list.is_empty());
    }

    #[test]
    fn addon_group_rule_truth_table() {
        let mut p = crate::agent::AgentProfile::default_for_tests();
        p.addons.push(AddonRef {
            id: "grp".into(),
            source: "claude-local:grp@1.0.0".into(),
            enabled: false,
            skills: vec!["g_skill".into()],
            mcp: vec!["g_mcp".into()],
            commands: vec!["g_cmd".into()],
            content_hash: None,
            fetch_ref: None,
            fetch_plugin: None,
        });

        // 1. standalone item, no entry anywhere => enabled (back-compat)
        assert!(p.skill_enabled("standalone"));
        assert!(p.mcp_enabled("standalone_mcp"));

        // 2. grouped item, group disabled => off (cannot enable one member of a disabled group)
        assert!(!p.skill_enabled("g_skill"));
        assert!(!p.mcp_enabled("g_mcp"));

        // 3. grouped item, group enabled, name not denied => on
        assert!(p.set_addon_enabled("grp", true));
        assert!(p.skill_enabled("g_skill"));
        assert!(p.mcp_enabled("g_mcp"));

        // 4. name in denylist overrides an enabled group => off (silence one member)
        p.set_skill_enabled("g_skill", false);
        assert!(!p.skill_enabled("g_skill"));

        // set_addon_enabled on a missing id reports false
        assert!(!p.set_addon_enabled("nope", true));

        // kill-switch: only flips group flags — no denylist push
        p.disable_all_addons();
        assert!(p.addons.iter().all(|g| !g.enabled));
        assert!(!p.skill_enabled("g_skill"));
        assert!(!p.skill_enabled("g_cmd"));
        assert!(!p.mcp_enabled("g_mcp")); // mcp kill-switch asserted

        // re-enable restores members — kill-switch is NOT sticky
        // (g_skill was individually denied in step 4 above and stays off;
        //  g_cmd and g_mcp were never individually denied so they come back on)
        assert!(p.set_addon_enabled("grp", true));
        assert!(!p.skill_enabled("g_skill")); // still individually denied from step 4
        assert!(p.skill_enabled("g_cmd")); // restored: never individually denied
        assert!(p.mcp_enabled("g_mcp")); // restored: never individually denied

        // clearing the individual deny fully restores g_skill too
        p.set_skill_enabled("g_skill", true);
        assert!(p.skill_enabled("g_skill"));
    }

    #[test]
    fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
        // legacy AddonRef (no new fields) → None
        let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
        let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
        assert_eq!(r.content_hash, None);
        assert_eq!(r.fetch_ref, None);

        // with the new fields → round-trips
        let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
        let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
        assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
        assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
        let back = serde_yaml_ng::to_string(&r2).unwrap();
        let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
        assert_eq!(r2, r3);
    }
}