Skip to main content

mur_common/
agent.rs

1//! Agent profile, Agent Card, and LockFile types shared between
2//! mur-agent-runtime and mur-core.
3
4use crate::companion::{Formality, Relationship};
5use crate::deps::ProgramDep;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// Skill metadata broadcast in the Agent Card (Layer 1 + Layer 2).
10///
11/// Populated by `mur skill install` (registry or agent:// URL). Distinct from
12/// `AgentProfile.skills`, which is the legacy per-agent-path list managed by
13/// `mur agent skill add`.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
15pub struct SkillCardEntry {
16    pub name: String,
17    #[serde(default, skip_serializing_if = "String::is_empty")]
18    pub version: String,
19    #[serde(default, skip_serializing_if = "String::is_empty")]
20    pub publisher: String,
21    #[serde(default, skip_serializing_if = "String::is_empty")]
22    pub description: String,
23    #[serde(default, skip_serializing_if = "String::is_empty")]
24    pub category: String,
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub tags: Vec<String>,
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub triggers: Vec<SkillCardTrigger>,
29    /// Layer 2 abstract — injected at session start (~200 tokens).
30    /// On-disk YAML key is `abstract` (a Rust reserved word).
31    #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
32    pub abstract_text: String,
33    /// Provenance chain copied from the installed manifest. Empty for
34    /// registry-installed skills.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub transfer_chain: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
40pub struct SkillCardTrigger {
41    #[serde(rename = "type")]
42    pub kind: String,
43    #[serde(default, skip_serializing_if = "String::is_empty")]
44    pub pattern: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct AgentProfile {
49    pub schema: u32,
50    pub id: String, // UUIDv7
51    pub name: String,
52    pub display_name: String,
53    /// Coarse human-facing role for grouping/filtering (e.g. "Engineer").
54    /// A free label, not a registry — bundled defaults are UI suggestions and
55    /// users can type their own. Also the SOFT signal in the dispatch index
56    /// (`agent_facts`), where it explains and ranks candidates but never
57    /// filters them: what an agent may actually do is decided by
58    /// `entitlements`, which the kernel enforces and a stale label cannot
59    /// overstate.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub role: Option<String>,
62    /// How hard this agent's model should work per turn
63    /// (`low`/`medium`/`high`/`xhigh`/`max`). `None` leaves the field off,
64    /// which is the API default (`high`) — not "no effort".
65    ///
66    /// Set it where the agent's JOB is known: a single-purpose build
67    /// specialist earns `xhigh`, a fan-out research worker `medium`, a
68    /// classifier `low`. Narrowed to what the resolved model accepts at the
69    /// client boundary, so an agent pinned to an older model degrades rather
70    /// than 400s.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub effort: Option<crate::llm::Effort>,
73    pub version: String,
74    pub persona: Persona,
75    pub sys_prompt_file: String,
76    pub model: ModelConfig,
77    /// Optional pointer into ~/.mur/models.yaml. When set, the runtime
78    /// prefers the registry entry over the inline `model:` block.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub model_ref: Option<String>,
81    /// Per-agent fallback chain (ordered model_refs). Overrides the global
82    /// `models.fallback_chain` when non-empty. See the model-switch spec.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub fallback_chain: Vec<String>,
85    /// Per-agent difficulty-routing override. Absent fields inherit the global
86    /// `models.routing`.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub routing: Option<crate::config::RoutingOverride>,
89    /// Per-agent Smart background-routing override. Absent fields inherit the
90    /// global `models.smart`; `None` means "follow the global setting".
91    /// Promoted out of `routing` — nesting it there meant overriding Smart
92    /// silently rewrote this agent's difficulty routing as a side effect.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub smart: Option<crate::config::SmartOverride>,
95    #[serde(default)]
96    pub mcp_servers: Vec<McpServerEntry>,
97    #[serde(default)]
98    pub skills: Vec<String>,
99    /// Skills installed via `mur skill install`. Distinct from `skills`
100    /// (which holds legacy per-agent paths from `mur agent skill add`).
101    /// Broadcast in the Agent Card alongside `skills`.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub installed_skills: Vec<SkillCardEntry>,
104    /// Per-agent skill denylist (add-on Phase 1). Skill names that are
105    /// installed/visible to this agent but suppressed from injection.
106    /// Non-destructive: the skill's files/stats are untouched. Empty = all
107    /// visible skills enabled (back-compat: absent in old profiles).
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub disabled_skills: Vec<String>,
110
111    /// Per-agent MCP denylist (add-on Phase 1). `McpServerEntry` names not
112    /// spawned for this agent. Non-destructive: the entry + its pin stay in
113    /// the profile. Empty = all configured servers enabled.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub disabled_mcp: Vec<String>,
116    /// Plugin-groups imported by this agent (add-on Phase 2). Each is
117    /// self-contained (members installed per-agent). Absent/empty in
118    /// legacy profiles (back-compat).
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub addons: Vec<AddonRef>,
121    pub transport: TransportConfig,
122    pub communication: CommunicationConfig,
123    #[serde(default)]
124    pub capabilities: Vec<String>,
125    pub entitlements: Entitlements,
126    #[serde(default)]
127    pub notifications: NotificationsConfig,
128    pub retry: RetryConfig,
129    pub lifecycle: LifecycleConfig,
130    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
131    /// (legacy P0a profiles continue to load without this block).
132    #[serde(default)]
133    pub identity: IdentityConfig,
134    #[serde(default)]
135    pub file_transfer: FileTransferConfig,
136    #[serde(default)]
137    pub deployment: DeploymentConfig,
138    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
139    /// continue to load without this block).
140    #[serde(default)]
141    pub companion: CompanionConfig,
142    /// Human-in-the-loop configuration (Phase 2). Default = disabled.
143    #[serde(default)]
144    pub hitl: HitlConfig,
145    /// Voice I/O configuration (D1). Default = disabled.
146    #[serde(default)]
147    pub voice: VoiceConfig,
148    /// A1: config-driven handler picker. Absent block = all defaults.
149    #[serde(default)]
150    pub hooks: crate::HooksConfig,
151    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
152    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
153    #[serde(default)]
154    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
155    pub created_at: String,
156    pub updated_at: String,
157    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
158    #[serde(default)]
159    pub appearance: AgentAppearance,
160    /// E6: Pattern federation — snapshot filter + outbox config.
161    #[serde(default)]
162    pub federation: FederationConfig,
163
164    /// A1: declarative UI action list — file_actions rendered as action
165    /// buttons in the pending-item selection UI. New top-level key; NOT
166    /// nested under `capabilities:`.
167    #[serde(default)]
168    pub file_actions: Vec<crate::action::FileAction>,
169
170    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
171    #[serde(default)]
172    pub action_pipeline: crate::action::ActionPipelineConfig,
173
174    /// External programs this artifact needs at runtime (portable-deps spec).
175    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub requires_programs: Vec<ProgramDep>,
178
179    /// Capability refs installed into this agent (Pack S3). Absent → empty;
180    /// resolved against the local capability registry / bundle store.
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub requires_capabilities: Vec<String>,
183}
184
185fn default_algorithm() -> String {
186    "ed25519".into()
187}
188
189/// Algorithms the runtime can generate + verify.
190pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
193pub struct IdentityConfig {
194    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
195    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
196    #[serde(default)]
197    pub pubkey: String,
198    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub owner: Option<String>,
201
202    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
203    /// Cryptographic algorithm for this key. Defaults to "ed25519".
204    #[serde(default = "default_algorithm")]
205    pub algorithm: String,
206    /// Monotonic version counter; 0 = initial create, increments on each rotation.
207    #[serde(default)]
208    pub key_version: u32,
209    /// RFC3339 timestamp of when this key was created.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub created_at_key: Option<String>,
212    /// Previous public key (before most recent rotation). None if not rotated yet.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub previous_pubkey: Option<String>,
215    /// Version of the previous key. None if not rotated yet.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub previous_key_version: Option<u32>,
218    /// RFC3339 timestamp when grace period expires and old key is fully retired.
219    /// Only set during rotation; cleared once grace period ends.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub grace_expires_at: Option<String>,
222    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub rotated_at: Option<String>,
225    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub emergency_rekey_at: Option<String>,
228}
229
230impl Default for IdentityConfig {
231    fn default() -> Self {
232        Self {
233            pubkey: String::new(),
234            owner: None,
235            algorithm: default_algorithm(),
236            key_version: 0,
237            created_at_key: None,
238            previous_pubkey: None,
239            previous_key_version: None,
240            grace_expires_at: None,
241            rotated_at: None,
242            emergency_rekey_at: None,
243        }
244    }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248pub struct Persona {
249    pub category: PersonaCategory,
250    pub description: String,
251    pub traits: PersonaTraits,
252}
253
254#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
255#[serde(rename_all = "lowercase")]
256pub enum PersonaCategory {
257    Research,
258    Automation,
259    Monitor,
260    Notify,
261    Commerce,
262    Custom,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
266pub struct PersonaTraits {
267    pub tone: String,
268    pub risk: String,
269    pub verbosity: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
273pub struct ModelConfig {
274    pub provider: String,
275    pub name: String,
276    #[serde(default)]
277    pub params: BTreeMap<String, serde_yaml_ng::Value>,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
281pub struct McpServerEntry {
282    pub name: String,
283    pub command: String,
284    #[serde(default)]
285    pub args: Vec<String>,
286
287    /// SHA-256 (hex, lowercase) of the binary at `command`'s resolved
288    /// path, captured at install time. `None` means the entry was
289    /// added before B0 M9.1 (back-compat) and rule-6 enforcement is
290    /// not applied — the supervisor will warn but not block.
291    /// (B0 rule 6 / M9.1)
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub binary_sha256: Option<String>,
294
295    /// SHA-256 (hex, lowercase) of the canonical-JSON of the MCP's
296    /// `tools/list` response, captured at install time. `None` means
297    /// the install path skipped the description probe (e.g. the MCP
298    /// uses a non-stdio transport or the binary couldn't be reached)
299    /// or the entry pre-dates M9. (B0 rule 6 / M9.1)
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub description_hash: Option<String>,
302
303    /// Display-only publisher metadata captured at install time so
304    /// the user can recall what they consented to. `None` for older
305    /// entries. (B0 rule 6 / M9.1)
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub publisher: Option<McpPublisherInfo>,
308
309    /// RFC3339 timestamp of when the entry was added or last
310    /// re-approved by the user via `mur agent mcp pin`. Used by the
311    /// rug-pull dialog UX. `None` for older entries. (B0 rule 6 / M9.1)
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
314
315    /// Per-tool-call timeout for this server, in seconds. `None` uses the
316    /// runtime default. Slow tools (e.g. `video_analyze`: transcript fetch
317    /// + local-model map-reduce) need a longer budget than the default.
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub timeout_secs: Option<u32>,
320
321    /// Per-server outbound egress override. `None` = inherit the agent-level
322    /// policy (default; unchanged behavior). `Restricted` routes this server's
323    /// child through the runtime egress proxy with `allow_hosts` (advisory).
324    /// See `docs/superpowers/plans/2026-06-26-mcp-per-server-egress.md`.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub network: Option<McpServerNetwork>,
327
328    /// HTTP(S) base URL for a remote (Streamable-HTTP or SSE) MCP server.
329    /// Mutually exclusive with `command` in practice; `None` = stdio transport.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub url: Option<String>,
332
333    /// Authentication credentials for a remote MCP server.
334    /// `None` = no auth (or stdio transport).
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub auth: Option<McpAuth>,
337
338    /// External programs this artifact needs at runtime (portable-deps spec).
339    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub requires_programs: Vec<ProgramDep>,
342
343    /// Vendored package this entry launches, when MUR installed it itself.
344    ///
345    /// Present only for entries moved off a package runner by
346    /// `mur agent mcp vendor`. Its existence is what makes the contents of an
347    /// interpreter-launched server verifiable at all: `npx @scope/pkg` resolves
348    /// on every spawn and pins nothing, whereas a vendored install lives in a
349    /// directory MUR owns and can be checked before the agent comes up.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub package: Option<McpPackagePin>,
352}
353
354/// A package MUR installed itself, and the fingerprint that proves the
355/// installed tree hasn't changed.
356///
357/// `lockfile_sha256` hashes the install's `package-lock.json`, which already
358/// records an integrity hash for every package in the dependency tree — so one
359/// small file covers the whole tree, and startup verification stays cheap no
360/// matter how large `node_modules` grows.
361///
362/// The lockfile pins what was *installed*. Editing a file inside
363/// `node_modules` afterwards would not change it; catching that needs a full
364/// tree hash, which is deliberately not done here — see the module docs on
365/// `mur-core::cmd::agent_mcp_vendor` for where that line is drawn.
366#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
367pub struct McpPackagePin {
368    /// Package ecosystem — `npm` today.
369    pub runner: String,
370    /// Package name, including any `@scope/` prefix.
371    pub name: String,
372    /// Exact installed version.
373    pub version: String,
374    /// Directory MUR installed into, absolute.
375    pub install_dir: String,
376    /// SHA-256 (lowercase hex) of `<install_dir>/package-lock.json`.
377    pub lockfile_sha256: String,
378
379    /// How many packages in the installed tree published no registry
380    /// signature, as reported by `npm audit signatures` at vendor time.
381    ///
382    /// `None` — the audit did not run (npm too old, or offline).
383    /// `Some(0)` — every package in the tree carried a verified signature.
384    /// `Some(n)` — `n` packages are unsigned; the rest verified.
385    ///
386    /// A signature that verifies proves the bytes came from the registry, which
387    /// the content hash cannot: it would faithfully pin a poisoned cache. An
388    /// *invalid* signature is not recorded here because it blocks the vendor
389    /// outright — that is an integrity failure, not a property to note.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub signatures_missing: Option<u32>,
392
393    /// SLSA predicate type of the package's build provenance, when it
394    /// publishes one — e.g. `https://slsa.dev/provenance/v1`. `None` means no
395    /// attestation was published (still the common case).
396    ///
397    /// Provenance ties a release back to a source repository and CI run, and
398    /// is the only signal here that can catch a **malicious publish**: a
399    /// content hash pins whatever was released, faithfully preserving a
400    /// poisoned version rather than detecting it. Recorded and shown, never
401    /// required — ecosystem coverage is far too thin to gate on.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub provenance: Option<String>,
404}
405
406impl McpPackagePin {
407    /// Name of the lockfile whose hash is `lockfile_sha256`.
408    ///
409    /// npm writes `package-lock.json` itself; for PyPI, MUR generates one with
410    /// `uv pip compile --generate-hashes`, which records a sha256 for every
411    /// package in the resolved tree — the same property that lets one small
412    /// file stand in for the whole install.
413    pub fn lockfile_name(&self) -> &'static str {
414        match self.runner.as_str() {
415            "pypi" => "requirements.lock",
416            _ => "package-lock.json",
417        }
418    }
419
420    /// Absolute path of the lockfile this pin covers.
421    ///
422    /// The startup check, `inspect`, and the deep audit all resolve it through
423    /// here, so a newly supported ecosystem cannot end up verified against the
424    /// wrong file in one of them and silently pass.
425    pub fn lockfile_path(&self) -> std::path::PathBuf {
426        std::path::Path::new(&self.install_dir).join(self.lockfile_name())
427    }
428}
429
430/// Authentication scheme for a remote (HTTP) MCP server.
431#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
432#[serde(rename_all = "snake_case", tag = "kind")]
433pub enum McpAuth {
434    /// Static bearer token stored as a secret reference.
435    Bearer { token: crate::secret::SecretRef },
436    /// OAuth 2.1 token, with dynamic client registration state.
437    Oauth(OauthAuth),
438}
439
440/// OAuth 2.1 state persisted alongside remote MCP entry.
441#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
442pub struct OauthAuth {
443    /// Authorization-server token endpoint (from discovery).
444    pub token_endpoint: String,
445    /// Client id from dynamic client registration.
446    pub client_id: String,
447    /// Keychain ref to access token.
448    pub access_token: crate::secret::SecretRef,
449    /// Keychain ref refresh token, if server issued one.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub refresh_token: Option<crate::secret::SecretRef>,
452    /// Unix-epoch seconds access token expires (0 = unknown).
453    #[serde(default)]
454    pub expires_at: u64,
455}
456
457/// How an MCP server's outbound network is scoped.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
459#[serde(rename_all = "snake_case")]
460pub enum McpNetMode {
461    /// No per-server policy and no proxy — the default.
462    ///
463    /// NOT "inherits `entitlements.network.outbound.allow_hosts`", despite the
464    /// name. That list is enforced in-process (a DNS guard on the runtime's own
465    /// HTTP client, plus the B0 gate on the agent's `network.*` tools), and a
466    /// spawned server never runs either. What a server here actually inherits
467    /// is the OS sandbox — which restricts by PORT, with the host left open.
468    ///
469    /// So an agent whose `allow_hosts` names one API still lets an `Inherit`
470    /// server reach any host on an allowed port. Use `Restricted` to bound a
471    /// server by host. The variant keeps its name because it is a serialized
472    /// wire value; the lie was the doc, and it is fixed here rather than
473    /// migrated.
474    #[default]
475    Inherit,
476    /// Allow only `allow_hosts`, routed through the runtime egress proxy.
477    Restricted,
478    /// Allow ALL hosts EXCEPT `deny_hosts`, routed through the runtime egress
479    /// proxy, with every CONNECT audited. For trusted-but-broad tools (e.g. a
480    /// web-research browser) that cannot enumerate their destinations. Requires
481    /// explicit operator consent (records `authorization`); downgraded to
482    /// `Inherit` on import (lowest trust). Advisory enforcement (see egress_proxy).
483    BroadAudited,
484    /// No outbound for this server at all.
485    Off,
486}
487
488/// Env var name a sandboxed MCP child reads to self-enforce the operator's
489/// `deny_hosts` overlay on connections the egress proxy cannot observe (e.g.
490/// `mur-research-gateway`'s tier-2/3 browser subprocesses — the proxy only
491/// sees tier-1 `reqwest` traffic). `mur-agent-runtime`'s `proxy_env_for` sets
492/// this on the child's env alongside the proxy vars; a cooperating child
493/// (currently `mur-research-gateway`, via `config::load`) reads it to source
494/// its own deny list. Single definition shared by both crates (CLAUDE.md
495/// rule 1: no duplicated literal).
496pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
497
498/// Per-MCP-server outbound egress policy.
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
500pub struct McpServerNetwork {
501    #[serde(default)]
502    pub mode: McpNetMode,
503    #[serde(default)]
504    pub allow_hosts: Vec<String>,
505    /// Deny overlay for `BroadAudited` mode: hosts blocked even though all
506    /// others are allowed. Ignored by `Restricted`/`Inherit`/`Off`.
507    #[serde(default)]
508    pub deny_hosts: Vec<String>,
509    /// Who authorized a `BroadAudited` grant, and when. `None` for other modes.
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub authorization: Option<EgressAuthorization>,
512}
513
514/// A plugin-group imported by one agent (add-on Phase 2). Self-contained:
515/// members are installed PER-AGENT (skills under
516/// `~/.mur/agents/<a>/skills/`, mcp appended to this profile's
517/// `mcp_servers`). No global library, no refcounting.
518///
519/// Fail-closed: `enabled` defaults to `false`. Only an explicit user
520/// toggle (CLI/Hub) or a trusted native installer flips it true — the
521/// importer always constructs it `false`.
522#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
523pub struct AddonRef {
524    /// e.g. "superpowers" (local) or "superpowers@claude-plugins-official".
525    pub id: String,
526    /// Provenance, free-text. e.g. "claude-local:superpowers@6.0.3".
527    pub source: String,
528    #[serde(default)]
529    pub enabled: bool,
530    #[serde(default, skip_serializing_if = "Vec::is_empty")]
531    pub skills: Vec<String>,
532    #[serde(default, skip_serializing_if = "Vec::is_empty")]
533    pub mcp: Vec<String>,
534    #[serde(default, skip_serializing_if = "Vec::is_empty")]
535    pub commands: Vec<String>,
536    /// Content-hash pin over the imported skill/command manifests, recorded
537    /// at import. `None` on legacy refs. Enables drift detection + refresh.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub content_hash: Option<String>,
540    /// The re-fetchable source (the original `import` argument: a local path
541    /// or `owner/repo`), distinct from the free-text provenance `source`.
542    /// `None` on legacy refs. Used by `reimport`.
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub fetch_ref: Option<String>,
545    /// The `--plugin <name>` selector used at import time to pick one plugin
546    /// out of a multi-plugin marketplace `fetch_ref`. `None` when the source
547    /// was a single-plugin dir/repo, or on legacy refs. Used by `reimport` so
548    /// a marketplace add-on can be re-fetched without re-specifying it.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub fetch_plugin: Option<String>,
551}
552
553/// Display-only publisher metadata captured at install time. None of
554/// the fields are validated against any external authority — they're
555/// shown to the user during the install confirm prompt and reproduced
556/// in `mur agent mcp inspect` output so the user can audit who they
557/// thought they were trusting. (B0 rule 6 / M9.1)
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
559pub struct McpPublisherInfo {
560    /// Free-form publisher identifier — e.g. `"Anthropic"`,
561    /// `"@github-user-alice"`, or whatever `serverInfo.name` returned.
562    pub name: String,
563
564    /// Optional homepage / docs URL. Best-effort: extracted from the
565    /// MCP's `serverInfo.metadata.homepage` or registry entry when
566    /// available; otherwise left unset.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub homepage: Option<String>,
569
570    /// Optional registry coordinate — e.g. `"@anthropic-mcp/weather@1.2.3"`.
571    /// Used purely for display; not consumed by any verification path.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub registry_id: Option<String>,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
577pub struct TransportConfig {
578    pub stdio: bool,
579    pub socket: SocketTransportConfig,
580    #[serde(default)]
581    pub tcp: TcpTransportConfig,
582    /// Track C5 — HTTP webhook receiver. Default off; enabling
583    /// requires an HMAC secret in the OS keychain (`SecretRef`).
584    /// See `docs/superpowers/specs/2026-05-05-mur-agent-c5-webhook-design.md`.
585    #[serde(default)]
586    pub webhook: WebhookTransportConfig,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
590pub struct TcpTransportConfig {
591    #[serde(default)]
592    pub enabled: bool,
593    #[serde(default)]
594    pub bind: String,
595    #[serde(default)]
596    pub noise: NoiseConfig,
597}
598
599/// HTTP webhook receiver — Track C5.
600///
601/// External systems POST `SharePayload`-shaped JSON to
602/// `http://<bind>:<port>/agents/<slug>/webhook` with an
603/// `X-Mur-Signature: sha256=<hex>` header carrying an HMAC-SHA256
604/// over the raw body. The HMAC secret is stored in the OS keychain
605/// via `SecretRef` (same pattern as Telegram bot tokens in C2);
606/// `hmac_secret_ref` is the `service:account` lookup key.
607///
608/// `bind` defaults to `127.0.0.1` so a fresh enable doesn't
609/// inadvertently expose the agent to the local network. Users who
610/// want VPN / Tailscale reachability override to `0.0.0.0` or the
611/// VPN interface address explicitly.
612#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
613pub struct WebhookTransportConfig {
614    #[serde(default)]
615    pub enabled: bool,
616    #[serde(default = "default_webhook_bind")]
617    pub bind: String,
618    #[serde(default = "default_webhook_port")]
619    pub port: u16,
620    /// `service:account` key into the OS keychain. Empty string
621    /// when `enabled = false`; required (and validated) at startup
622    /// when enabled.
623    #[serde(default)]
624    pub hmac_secret_ref: String,
625}
626
627fn default_webhook_bind() -> String {
628    "127.0.0.1".to_string()
629}
630
631fn default_webhook_port() -> u16 {
632    6789
633}
634
635impl Default for WebhookTransportConfig {
636    fn default() -> Self {
637        Self {
638            enabled: false,
639            bind: default_webhook_bind(),
640            port: default_webhook_port(),
641            hmac_secret_ref: String::new(),
642        }
643    }
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
647pub struct NoiseConfig {
648    pub pattern: String,
649}
650
651impl Default for NoiseConfig {
652    fn default() -> Self {
653        Self {
654            pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
655        }
656    }
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
660pub struct SocketTransportConfig {
661    pub enabled: bool,
662    pub bind: String, // "unix:///path" or "tcp://host:port" (P0b)
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub auth: Option<AuthConfig>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
668pub struct AuthConfig {
669    pub scheme: String,
670    pub token_file: String,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
674pub struct CommunicationConfig {
675    #[serde(default = "default_accepts_all")]
676    pub accepts_from: Vec<String>,
677    #[serde(default)]
678    pub sends_to: Vec<String>,
679}
680fn default_accepts_all() -> Vec<String> {
681    vec!["*".to_string()]
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
685pub struct Entitlements {
686    pub network: NetworkEntitlement,
687    pub filesystem: FilesystemEntitlement,
688    pub processes: ProcessesEntitlement,
689    #[serde(default)]
690    pub syscalls: SyscallsEntitlement,
691    #[serde(default)]
692    pub limits: LimitsEntitlement,
693    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
694    /// so the supervisor refuses to construct an LLM client.
695    #[serde(default)]
696    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
697    /// Per-tool allow/ask/deny policy. Empty = all tools use default (Ask).
698    #[serde(default, skip_serializing_if = "Vec::is_empty")]
699    pub tools: Vec<ToolRule>,
700    /// When `true` (the default), a sandbox apply failure is fatal: the agent
701    /// refuses to start rather than running advisory-only (unconfined).
702    /// Set to `false` only for development or trusted-workstation agents that
703    /// intentionally run without kernel sandbox enforcement.
704    #[serde(default = "default_true")]
705    pub fail_closed_on_sandbox_error: bool,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
709pub struct NetworkEntitlement {
710    pub inbound: InboundNetwork,
711    pub outbound: OutboundNetwork,
712}
713
714#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
715pub struct InboundNetwork {
716    #[serde(default)]
717    pub ports: Vec<u16>,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
721pub struct OutboundNetwork {
722    pub mode: NetworkOutboundMode,
723    #[serde(default)]
724    pub allow_hosts: Vec<String>,
725    #[serde(default = "default_protocols")]
726    pub protocols: Vec<String>,
727    #[serde(default)]
728    pub resolve_dns: ResolveDnsConfig,
729}
730fn default_protocols() -> Vec<String> {
731    vec!["tcp".to_string()]
732}
733
734/// Record of who authorized a broad egress grant, and when. Attached to a
735/// per-MCP-server `McpServerNetwork` when its mode is `BroadAudited`, so the
736/// grant is persisted, portable, and re-approvable on import.
737#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
738pub struct EgressAuthorization {
739    pub authorized_by: String,
740    pub authorized_at_ms: u64,
741}
742
743#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
744#[serde(rename_all = "lowercase")]
745pub enum NetworkOutboundMode {
746    Unrestricted,
747    Restricted,
748    /// Deny all general outbound TCP; egress is ONLY via loopback proxies
749    /// (the agent's cc-proxy LLM port + the egress proxy). Hostnames are still
750    /// governed by `allow_hosts` (HostGuard) — unlike `Off`, which blocks all.
751    ProxyOnly,
752    Off,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
756pub struct ResolveDnsConfig {
757    #[serde(default = "default_dns_mode")]
758    pub mode: String,
759    #[serde(default)]
760    pub servers: Vec<String>,
761}
762impl Default for ResolveDnsConfig {
763    fn default() -> Self {
764        Self {
765            mode: default_dns_mode(),
766            servers: vec![],
767        }
768    }
769}
770fn default_dns_mode() -> String {
771    "system".to_string()
772}
773
774/// Dirs under `<mur_home>` where MUR objects are authored.
775///
776/// The seeded concierge gets read+write on these; without them the one agent a
777/// fresh host has can describe a skill, workflow or fleet but cannot create
778/// one, and every answer ends in "run this command yourself".
779///
780/// Deliberately excludes `agents/`: `self_protected()` only covers an agent's
781/// OWN `profile.yaml` + `identity.key`, so write access there would let an
782/// agent author a sibling with unrestricted entitlements and start it, and
783/// read access would expose every other agent's Ed25519 signing key.
784pub const AUTHORING_DIRS: [&str; 4] = ["skills", "workflows", "fleets", "artifacts"];
785
786#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
787pub struct FilesystemEntitlement {
788    #[serde(default)]
789    pub read: Vec<String>,
790    #[serde(default)]
791    pub write: Vec<String>,
792    #[serde(default)]
793    pub deny: Vec<String>,
794}
795
796#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
797pub struct ProcessesEntitlement {
798    pub spawn: SpawnEntitlement,
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
802pub struct SpawnEntitlement {
803    pub mode: SpawnMode,
804    #[serde(default)]
805    pub allowed: Vec<String>,
806    /// Directories whose entire subtree may be exec'd — the "build lane".
807    ///
808    /// `allowed` cannot express a toolchain that compiles its own
809    /// executables: a Rust build execs `target/debug/build/<crate>-<hash>/
810    /// build-script-build`, proc-macro shims, and freshly linked test
811    /// binaries, all at paths that do not exist until the build creates them
812    /// and change on every dependency bump. Without this an agent granted
813    /// `cargo` could compile nothing and could never verify its own work.
814    ///
815    /// Grant narrowly — a build-output directory, not a source tree or a
816    /// home directory. Everything under it becomes exec'able, so the tree
817    /// should be one the agent already has write access to and nothing else
818    /// depends on. Filesystem and network entitlements still bound what the
819    /// executed code can reach.
820    #[serde(default)]
821    pub allowed_dirs: Vec<String>,
822}
823
824#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
825#[serde(rename_all = "lowercase")]
826pub enum SpawnMode {
827    Allowlist,
828    Any,
829    None,
830    /// Shell-only: fences the system exec paths (`/bin`, `/usr/bin`,
831    /// `/usr/lib`) that `Allowlist` mode exempts by default, so only the
832    /// resolved shell binary the `bash` tool itself spawns plus the
833    /// profile's own `spawn_allowed_paths`/`spawn_allowed_prefixes` may be
834    /// exec'd -- no other system binary (coreutils, `git`, etc.) is implied.
835    Strict,
836}
837
838#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
839pub struct SyscallsEntitlement {
840    #[serde(default = "default_syscalls_mode")]
841    pub mode: String,
842    #[serde(default)]
843    pub extra_deny: Vec<String>,
844}
845fn default_syscalls_mode() -> String {
846    "default".to_string()
847}
848
849#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
850pub struct LimitsEntitlement {
851    #[serde(default)]
852    pub cpu_seconds: Option<u64>,
853    #[serde(default = "default_memory_mb")]
854    pub memory_mb: u64,
855    #[serde(default = "default_fds")]
856    pub file_descriptors: u32,
857    #[serde(default = "default_procs")]
858    pub processes: u32,
859}
860fn default_memory_mb() -> u64 {
861    512
862}
863fn default_fds() -> u32 {
864    1024
865}
866fn default_procs() -> u32 {
867    32
868}
869
870#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
871#[serde(rename_all = "lowercase")]
872pub enum ToolPolicy {
873    Allow,
874    #[default]
875    Ask,
876    Deny,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
880pub struct ToolRule {
881    pub pattern: String,
882    pub policy: ToolPolicy,
883    /// Intrinsic risk tier of this tool (v3c). Resolved most-restrictive-wins
884    /// against per-step risk + channel policy; gates pre-execution when not Read.
885    #[serde(default, skip_serializing_if = "Option::is_none")]
886    pub risk: Option<crate::hitl::RiskTier>,
887}
888
889/// Resolve the effective policy for `tool_name` against an ordered rule list.
890///
891/// Precedence: exact-name match > longest-prefix glob (trailing `*`) > default (`Ask`).
892pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
893    resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
894}
895
896/// Like [`resolve_tool_policy`] but distinguishes "no rule matched" (`None`)
897/// from an explicit rule — for tools whose registration is already gated
898/// elsewhere (e.g. `fleet_run`'s config allowlist) and that therefore want a
899/// different default than `Ask` while still honoring explicit rules.
900pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
901    for rule in rules {
902        if rule.pattern == tool_name {
903            return Some(rule.policy);
904        }
905    }
906    let mut best: Option<(&ToolRule, usize)> = None;
907    for rule in rules {
908        if let Some(prefix) = rule.pattern.strip_suffix('*')
909            && tool_name.starts_with(prefix)
910        {
911            let len = prefix.len();
912            if best.is_none_or(|(_, best_len)| len > best_len) {
913                best = Some((rule, len));
914            }
915        }
916    }
917    best.map(|(rule, _)| rule.policy)
918}
919
920#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
921pub struct NotificationsConfig {
922    #[serde(default)]
923    pub on_task_complete: Vec<NotificationTarget>,
924    #[serde(default)]
925    pub on_error: Vec<NotificationTarget>,
926    #[serde(default)]
927    pub on_shutdown: Vec<NotificationTarget>,
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
931#[serde(tag = "target", rename_all = "lowercase")]
932pub enum NotificationTarget {
933    Agent {
934        name: String,
935    },
936    Commander,
937    Email {
938        address: String,
939        #[serde(default)]
940        smtp_config_file: Option<String>,
941    },
942    Slack {
943        #[serde(default)]
944        channel: Option<String>,
945        #[serde(default)]
946        webhook_url_env: Option<String>,
947    },
948    Webpush {
949        url: String,
950    },
951    Webhook {
952        url: String,
953        #[serde(default = "default_post")]
954        method: String,
955        #[serde(default)]
956        auth: Option<String>,
957    },
958}
959fn default_post() -> String {
960    "POST".to_string()
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
964pub struct RetryConfig {
965    pub llm: RetryPolicy,
966    pub tool: RetryPolicy,
967}
968
969#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
970pub struct RetryPolicy {
971    pub max_retries: u32,
972    pub backoff: BackoffStrategy,
973    pub initial_delay_ms: u64,
974    #[serde(default)]
975    pub max_delay_ms: Option<u64>,
976    #[serde(default)]
977    pub retry_on: Vec<String>,
978}
979
980#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
981#[serde(rename_all = "lowercase")]
982pub enum BackoffStrategy {
983    Linear,
984    Exponential,
985    Fixed,
986}
987
988#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
989pub struct LifecycleConfig {
990    pub restart: RestartPolicy,
991    #[serde(default = "default_max_restarts")]
992    pub max_restarts: u32,
993    #[serde(default = "default_window")]
994    pub restart_window_secs: u64,
995    #[serde(default = "default_stop_timeout")]
996    pub stop_timeout_secs: u64,
997    #[serde(default = "default_mcp_required")]
998    pub mcp_required: bool,
999    #[serde(default)]
1000    pub execution: ExecutionMode,
1001    #[serde(default)]
1002    pub schedule: Vec<ScheduleEntry>,
1003    #[serde(default)]
1004    pub idle_triggers: Vec<IdleTrigger>,
1005}
1006fn default_max_restarts() -> u32 {
1007    3
1008}
1009fn default_window() -> u64 {
1010    600
1011}
1012fn default_stop_timeout() -> u64 {
1013    15
1014}
1015fn default_mcp_required() -> bool {
1016    true
1017}
1018
1019#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1020#[serde(rename_all = "snake_case")]
1021pub enum RestartPolicy {
1022    Never,
1023    OnFailure,
1024    Always,
1025}
1026
1027#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1028#[serde(rename_all = "snake_case")]
1029pub enum ExecutionMode {
1030    #[default]
1031    Daemon,
1032    OnDemand,
1033}
1034
1035/// Where an agent leaves a schedule it wants but cannot create.
1036///
1037/// An agent's schedules live in `lifecycle.schedule` inside its own
1038/// `profile.yaml`, and an agent may not write that file — the sandbox denies it
1039/// unconditionally so a running agent cannot widen its own entitlements and
1040/// restart into them. So "remind me at 10 tomorrow" cannot become a schedule
1041/// from the inside, however much the agent understands the request.
1042///
1043/// It becomes a proposal instead: a file in the agent's own home, which it may
1044/// write, that `mur agent schedule accept` turns into the real entry.
1045///
1046/// Public and shared because both halves must name the same directory. Two
1047/// spellings would not fail loudly — the agent would write proposals nobody
1048/// lists, which is the shape of failure this whole area keeps producing.
1049pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
1050
1051/// File in the agent's home holding the id of the channel a fired schedule
1052/// leaves its reply in. One stable channel per agent, remembered rather than
1053/// re-derived (#1125).
1054pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
1055
1056/// A schedule an agent asked for and a person has not yet granted.
1057#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1058pub struct ScheduleProposal {
1059    pub cron: String,
1060    pub message: String,
1061    /// What the user actually said, kept verbatim: a cron expression is not
1062    /// reviewable on its own, and the reviewer is being asked whether this is
1063    /// what they meant.
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub asked_for: Option<String>,
1066    /// Proposed bound, carried verbatim onto the accepted [`ScheduleEntry`].
1067    /// Present exactly when the agent judged the request to name one occasion
1068    /// rather than a recurrence.
1069    #[serde(default, skip_serializing_if = "Option::is_none")]
1070    pub not_after: Option<String>,
1071    pub proposed_at: String,
1072}
1073
1074#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1075pub struct ScheduleEntry {
1076    pub cron: String,
1077    pub message: String,
1078    #[serde(default, skip_serializing_if = "Option::is_none")]
1079    pub sends_to: Option<String>,
1080    /// Retire the entry once its next firing would fall after this instant
1081    /// (RFC3339 with offset). How a one-shot reminder is expressed: cron has no
1082    /// year field, so "tomorrow at 10:00" can only be written as an annual
1083    /// recurrence, and unbounded it turns a request for one morning into a
1084    /// perpetual commitment (#1119).
1085    ///
1086    /// A bound rather than a fired-yet flag, because the scheduler runs inside
1087    /// the agent's own sandbox where `profile.yaml` is denied
1088    /// (`SELF_PROTECTED_AGENT_FILES`, #712) — it cannot record that an entry has
1089    /// fired. Comparing the next firing against a stored instant needs no write
1090    /// at all, so the bound works where a flag structurally could not.
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub not_after: Option<String>,
1093}
1094
1095#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1096pub struct IdleTrigger {
1097    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
1098    pub after_secs: u64,
1099    /// Message body injected into the task runner when this trigger fires.
1100    pub message: String,
1101    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
1102    #[serde(default, skip_serializing_if = "Option::is_none")]
1103    pub sends_to: Option<String>,
1104    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
1105    /// idle threshold is short and the runner finishes quickly. Default 600.
1106    #[serde(default = "default_idle_cooldown")]
1107    pub cooldown_secs: u64,
1108    /// When true, suppress firing during the agent's quiet-hours window.
1109    /// Default true — idle pings should not wake the user at 3 a.m.
1110    #[serde(default = "default_true")]
1111    pub respect_quiet_hours: bool,
1112}
1113
1114fn default_idle_cooldown() -> u64 {
1115    600
1116}
1117/// True if `name` is not present in a denylist (i.e. enabled).
1118pub fn name_enabled(denylist: &[String], name: &str) -> bool {
1119    !denylist.iter().any(|n| n == name)
1120}
1121
1122/// Add/remove `name` in a denylist. `enabled=true` removes it (idempotent),
1123/// `enabled=false` adds it once (idempotent).
1124pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
1125    if enabled {
1126        list.retain(|n| n != name);
1127    } else if !list.iter().any(|n| n == name) {
1128        list.push(name.to_string());
1129    }
1130}
1131
1132fn default_true() -> bool {
1133    true
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1137pub struct FileTransferConfig {
1138    #[serde(default = "default_accept_max")]
1139    pub accept_incoming_file_max_bytes: u64,
1140    #[serde(default = "default_accept_total")]
1141    pub accept_incoming_total_per_hour: u64,
1142    #[serde(default = "default_approval_threshold")]
1143    pub require_approval_above_bytes: u64,
1144    #[serde(default = "default_reject_paths")]
1145    pub reject_paths: Vec<String>,
1146    #[serde(default = "default_allowed_mime")]
1147    pub allowed_mime_types: Vec<String>,
1148}
1149
1150impl Default for FileTransferConfig {
1151    fn default() -> Self {
1152        Self {
1153            accept_incoming_file_max_bytes: default_accept_max(),
1154            accept_incoming_total_per_hour: default_accept_total(),
1155            require_approval_above_bytes: default_approval_threshold(),
1156            reject_paths: default_reject_paths(),
1157            allowed_mime_types: default_allowed_mime(),
1158        }
1159    }
1160}
1161
1162fn default_accept_max() -> u64 {
1163    10_485_760
1164}
1165fn default_accept_total() -> u64 {
1166    104_857_600
1167}
1168fn default_approval_threshold() -> u64 {
1169    10_485_760
1170}
1171fn default_reject_paths() -> Vec<String> {
1172    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1173}
1174fn default_allowed_mime() -> Vec<String> {
1175    vec!["*".into()]
1176}
1177
1178#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1179#[serde(rename_all = "snake_case")]
1180pub enum DeploymentType {
1181    #[default]
1182    Laptop,
1183    Vm,
1184    Docker,
1185    K8s,
1186    Lambda,
1187}
1188
1189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1190pub struct DeploymentConfig {
1191    #[serde(rename = "type", default)]
1192    pub deployment_type: DeploymentType,
1193    #[serde(default, skip_serializing_if = "Option::is_none")]
1194    pub region: Option<String>,
1195    #[serde(default = "default_env")]
1196    pub environment: Option<String>,
1197}
1198
1199impl Default for DeploymentConfig {
1200    fn default() -> Self {
1201        Self {
1202            deployment_type: DeploymentType::default(),
1203            region: None,
1204            environment: default_env(),
1205        }
1206    }
1207}
1208
1209fn default_env() -> Option<String> {
1210    Some("dev".into())
1211}
1212
1213/// One filesystem grant the sandbox refused to install, and why.
1214///
1215/// The grant stays in `profile.yaml` — this records that it did not reach the
1216/// kernel, which is otherwise knowable only from a WARN line in a log nobody
1217/// queries.
1218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1219pub struct DroppedGrant {
1220    pub path: String,
1221    /// `"read"` or `"write"`.
1222    pub verb: String,
1223    pub reason: String,
1224}
1225
1226/// Digest of the filesystem half of a profile's entitlements.
1227///
1228/// Narrower than `card_digest` on purpose: that one moves whenever any profile
1229/// field does, so using it to flag "grants changed since this agent started"
1230/// would raise a false alarm on an unrelated edit — and a status line that
1231/// cries wolf is one people stop reading.
1232pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
1233    use sha2::{Digest, Sha256};
1234    let mut h = Sha256::new();
1235    for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
1236        let mut sorted = list.clone();
1237        sorted.sort();
1238        for p in sorted {
1239            h.update(label.as_bytes());
1240            h.update(b"\0");
1241            h.update(p.as_bytes());
1242            h.update(b"\0");
1243        }
1244    }
1245    format!("sha256:{:x}", h.finalize())
1246}
1247
1248/// What the sandbox actually installed, recorded at the moment it sealed.
1249///
1250/// A seatbelt profile cannot be widened after `sandbox_init`, so this is fixed
1251/// for the process's lifetime — the same lifetime as the lock file it rides in.
1252/// Without it, `profile.yaml` is the only readable account of an agent's
1253/// permissions, and it describes what was asked for rather than what took
1254/// effect.
1255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1256pub struct SandboxRecord {
1257    /// False means the kernel sandbox is NOT installed and only advisory hooks
1258    /// remain — the agent then has MORE access than its profile grants, which
1259    /// is the opposite of every other failure here and the one worth shouting.
1260    pub enforcing: bool,
1261    /// `"macos-sbpl"`, `"linux-landlock"`, `"advisory-only"`, …
1262    pub mode: String,
1263    /// Digest of `entitlements.filesystem` as sealed. Comparing it against the
1264    /// profile on disk answers "were grants changed since this agent started"
1265    /// without anyone tracking that — and unlike `card_digest` it does not move
1266    /// when an unrelated field does, so it cannot raise a false alarm.
1267    pub granted_digest: String,
1268    /// Grants that did not reach the kernel. Empty is the normal case.
1269    #[serde(default)]
1270    pub dropped: Vec<DroppedGrant>,
1271}
1272
1273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1274pub struct LockFile {
1275    pub schema: u32,
1276    pub uuid: String,
1277    pub name: String,
1278    pub pid: u32,
1279    pub ppid: u32,
1280    pub started_at: String,
1281    pub binary_version: String,
1282    pub transports: LockTransports,
1283    pub card_digest: String,
1284    pub capabilities: Vec<String>,
1285    /// Git sha the running binary was built from (mur_common::build::SHORT_SHA).
1286    /// Empty = an old lock predating this field. Drives stale detection.
1287    #[serde(default)]
1288    pub build_sha: String,
1289    /// A2A method-surface version this runtime supports (A2A_PROTO_VERSION).
1290    /// 0 = an old lock; the dial gates versioned methods on it.
1291    #[serde(default)]
1292    pub proto_version: u32,
1293    /// What the sandbox installed at seal time. `None` = a lock written before
1294    /// this field existed, or a platform that installs no sandbox.
1295    #[serde(default, skip_serializing_if = "Option::is_none")]
1296    pub sandbox: Option<SandboxRecord>,
1297}
1298
1299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1300pub struct LockTransports {
1301    pub stdio: bool,
1302    #[serde(default)]
1303    pub unix_socket: Option<String>,
1304    #[serde(default)]
1305    pub tcp: Option<String>,
1306    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
1307    /// Populated by the supervisor when `transport.webhook.enabled =
1308    /// true` so peers and the commander can discover the live
1309    /// endpoint without re-reading `profile.yaml`.
1310    #[serde(default)]
1311    pub webhook: Option<String>,
1312}
1313
1314// ──────────────────────────────────────────────────────────────────────────
1315// Voice I/O configuration (D1 — Kokoro 82M TTS + whisper.cpp STT)
1316// ──────────────────────────────────────────────────────────────────────────
1317
1318/// Kokoro 82M voice identity. Maps to the per-voice style vector
1319/// embedded in the Kokoro ONNX model.
1320#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1321#[serde(rename_all = "snake_case")]
1322pub enum VoiceId {
1323    /// Default: Kokoro af_heart voice.
1324    #[default]
1325    AfHeart,
1326    AfBella,
1327    AfNicole,
1328    AmAdam,
1329    AmMichael,
1330}
1331
1332impl VoiceId {
1333    /// Index into the Kokoro voices.bin style matrix (row index).
1334    pub fn style_index(&self) -> usize {
1335        match self {
1336            VoiceId::AfHeart => 0,
1337            VoiceId::AfBella => 1,
1338            VoiceId::AfNicole => 2,
1339            VoiceId::AmAdam => 3,
1340            VoiceId::AmMichael => 4,
1341        }
1342    }
1343
1344    /// Canonical lowercase string representation (matches `FromStr` inputs).
1345    pub fn as_str(&self) -> &'static str {
1346        match self {
1347            VoiceId::AfHeart => "af_heart",
1348            VoiceId::AfBella => "af_bella",
1349            VoiceId::AfNicole => "af_nicole",
1350            VoiceId::AmAdam => "am_adam",
1351            VoiceId::AmMichael => "am_michael",
1352        }
1353    }
1354}
1355
1356impl std::str::FromStr for VoiceId {
1357    type Err = anyhow::Error;
1358
1359    fn from_str(s: &str) -> anyhow::Result<Self> {
1360        match s {
1361            "af_heart" => Ok(VoiceId::AfHeart),
1362            "af_bella" => Ok(VoiceId::AfBella),
1363            "af_nicole" => Ok(VoiceId::AfNicole),
1364            "am_adam" => Ok(VoiceId::AmAdam),
1365            "am_michael" => Ok(VoiceId::AmMichael),
1366            other => anyhow::bail!(
1367                "unknown voice ID '{other}' \
1368                 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1369            ),
1370        }
1371    }
1372}
1373
1374/// Per-agent voice I/O configuration (D1).
1375/// Default = disabled so existing profiles continue to load unchanged.
1376#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1377pub struct VoiceConfig {
1378    /// Whether TTS (Kokoro) + STT (whisper.cpp) are enabled.
1379    #[serde(default)]
1380    pub enabled: bool,
1381    /// Kokoro voice identity for TTS output. Default: af_heart.
1382    #[serde(default)]
1383    pub voice_id: VoiceId,
1384    /// Optional cpal input device name for mic capture.
1385    /// None means the OS default input device.
1386    #[serde(default, skip_serializing_if = "Option::is_none")]
1387    pub input_device: Option<String>,
1388}
1389
1390// ──────────────────────────────────────────────────────────────────────────
1391// Human-in-the-loop configuration (Phase 2)
1392// ──────────────────────────────────────────────────────────────────────────
1393
1394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1395pub struct HitlConfig {
1396    #[serde(default = "default_hitl_timeout_secs")]
1397    pub timeout_secs: u32,
1398    /// Hard cap on agentic-loop iterations (one LLM turn + its tool calls).
1399    /// `None` falls back to the runner default (25). On exceeding the cap the
1400    /// loop exits gracefully with a summary, not a hard error.
1401    #[serde(default)]
1402    pub max_iterations: Option<u32>,
1403    /// Per-task ceiling on cumulative *input* tokens for the agentic loop. When
1404    /// crossed before a turn, the loop stops gracefully with a summary.
1405    /// `None` falls back to the runner default (750_000 ≈ a few dollars on
1406    /// Sonnet); set a lower value per profile to bound spend tightly.
1407    #[serde(default)]
1408    pub max_tokens: Option<u64>,
1409}
1410
1411fn default_hitl_timeout_secs() -> u32 {
1412    300
1413}
1414
1415impl Default for HitlConfig {
1416    fn default() -> Self {
1417        Self {
1418            timeout_secs: default_hitl_timeout_secs(),
1419            max_iterations: None,
1420            max_tokens: None,
1421        }
1422    }
1423}
1424
1425#[cfg(test)]
1426mod hitl_tests {
1427    use super::*;
1428
1429    #[test]
1430    fn hitl_config_default_max_iterations_is_none() {
1431        let cfg = HitlConfig::default();
1432        assert!(cfg.max_iterations.is_none());
1433    }
1434
1435    #[test]
1436    fn hitl_config_max_iterations_explicit() {
1437        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1438        assert_eq!(cfg.max_iterations, Some(5));
1439    }
1440
1441    #[test]
1442    fn hitl_config_default_max_tokens_is_none() {
1443        let cfg = HitlConfig::default();
1444        assert!(cfg.max_tokens.is_none());
1445    }
1446
1447    #[test]
1448    fn hitl_config_max_tokens_explicit() {
1449        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1450        assert_eq!(cfg.max_tokens, Some(250_000));
1451    }
1452}
1453
1454// ──────────────────────────────────────────────────────────────────────────
1455// Companion subsystem (Phase 1.1+) — see
1456// docs/superpowers/specs/2026-04-29-mur-companion-phase-1-1-design.md §3.1
1457// ──────────────────────────────────────────────────────────────────────────
1458
1459#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1460pub struct CompanionConfig {
1461    #[serde(default)]
1462    pub enabled: bool,
1463    #[serde(default = "default_locale")]
1464    pub locale: String,
1465    #[serde(default)]
1466    pub relationship: Relationship,
1467    #[serde(default)]
1468    pub voice_overrides: VoiceOverrides,
1469    #[serde(default)]
1470    pub onboarding: OnboardingState,
1471    #[serde(default)]
1472    pub rhythm: RhythmConfig,
1473    #[serde(default)]
1474    pub proactive: ProactiveConfig,
1475}
1476
1477/// Resolve a default BCP-47 locale: the OS locale first (sys-locale already
1478/// returns BCP-47, e.g. `zh-Hant-TW`), then the `LANG` environment variable
1479/// (POSIX form `zh_TW.UTF-8` → `zh-TW`), then `en-US`.
1480///
1481/// OS-first matters because this is also the serde default for
1482/// `AgentProfile.locale`: under launchd there is no `LANG`, so the old
1483/// LANG-only resolution silently defaulted every headless agent to `en-US`
1484/// even on a non-English system.
1485pub fn default_locale() -> String {
1486    sys_locale::get_locale()
1487        .filter(|l| !l.is_empty())
1488        .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
1489        .unwrap_or_else(|| "en-US".into())
1490}
1491
1492/// Parse a POSIX-style `LANG` value into BCP-47 (`zh_TW.UTF-8` → `zh-TW`).
1493fn normalize_lang(v: &str) -> Option<String> {
1494    v.split('.')
1495        .next()
1496        .map(|s| s.replace('_', "-"))
1497        .filter(|s| !s.is_empty())
1498}
1499
1500#[cfg(test)]
1501mod locale_tests {
1502    use super::normalize_lang;
1503
1504    #[test]
1505    fn lang_with_encoding_and_region_normalizes() {
1506        assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
1507    }
1508
1509    #[test]
1510    fn lang_without_encoding_normalizes() {
1511        assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
1512    }
1513
1514    #[test]
1515    fn lang_with_script_keeps_script() {
1516        assert_eq!(
1517            normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
1518            Some("zh-Hant-TW")
1519        );
1520    }
1521
1522    #[test]
1523    fn empty_lang_yields_none() {
1524        assert_eq!(normalize_lang(""), None);
1525    }
1526}
1527
1528#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1529pub struct VoiceOverrides {
1530    #[serde(default, skip_serializing_if = "Option::is_none")]
1531    pub name_for_user: Option<String>,
1532    #[serde(default, skip_serializing_if = "Option::is_none")]
1533    pub formality: Option<Formality>,
1534    #[serde(default, skip_serializing_if = "Option::is_none")]
1535    pub extra_instructions: Option<String>,
1536}
1537
1538#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1539pub struct FirstMemory {
1540    pub text: String,
1541    pub established_at: chrono::DateTime<chrono::Utc>,
1542}
1543
1544#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1545pub struct OnboardingState {
1546    #[serde(default, skip_serializing_if = "Option::is_none")]
1547    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1548    #[serde(default)]
1549    pub version: u32,
1550    #[serde(default, skip_serializing_if = "Option::is_none")]
1551    pub agent_display_name: Option<String>,
1552    #[serde(default, skip_serializing_if = "Option::is_none")]
1553    pub first_memory: Option<FirstMemory>,
1554}
1555
1556/// Phase 1.2 reservation. 1.1 keeps `enabled = false` (rhythm collection is
1557/// out of 1.1 scope).
1558#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1559pub struct RhythmConfig {
1560    #[serde(default)]
1561    pub enabled: bool,
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1565pub struct ProactiveConfig {
1566    #[serde(default)]
1567    pub enabled: bool,
1568    /// 1.1 reserves the field; 1.2 will write `now + 7d` at rhythm-enable.
1569    #[serde(default, skip_serializing_if = "Option::is_none")]
1570    pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1571    #[serde(default, skip_serializing_if = "Option::is_none")]
1572    pub quiet_hours: Option<QuietHours>,
1573    #[serde(default, skip_serializing_if = "Option::is_none")]
1574    pub active_hours: Option<ActiveHours>,
1575    #[serde(default = "default_daily_cap")]
1576    pub daily_cap: u8,
1577    #[serde(default = "default_channels")]
1578    pub channels: Vec<String>,
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1581}
1582
1583impl Default for ProactiveConfig {
1584    fn default() -> Self {
1585        Self {
1586            enabled: false,
1587            learning_until: None,
1588            quiet_hours: None,
1589            active_hours: None,
1590            daily_cap: default_daily_cap(),
1591            channels: default_channels(),
1592            paused_until: None,
1593        }
1594    }
1595}
1596
1597fn default_daily_cap() -> u8 {
1598    3
1599}
1600fn default_channels() -> Vec<String> {
1601    vec!["stdout".into()]
1602}
1603
1604#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1605pub struct QuietHours {
1606    pub start: String,
1607    pub end: String,
1608}
1609
1610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1611pub struct ActiveHours {
1612    pub start: String,
1613    pub end: String,
1614}
1615
1616// ──────────────────────────────────────────────────────────────────────────
1617// Hub companion appearance (M-h3)
1618// ──────────────────────────────────────────────────────────────────────────
1619
1620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1621pub struct AgentAppearance {
1622    /// ID of the active style preset (e.g. "chiikawa", "default-blob").
1623    #[serde(default = "default_style_preset")]
1624    pub style_preset: String,
1625    #[serde(default)]
1626    pub behavior_preset: BehaviorPreset,
1627    /// Required for the polaroid family; none for all others.
1628    #[serde(default, skip_serializing_if = "Option::is_none")]
1629    pub source_image_path: Option<std::path::PathBuf>,
1630    /// Local dir where rendered .webp expression frames are stored.
1631    #[serde(default = "default_expressions_dir")]
1632    pub expressions_dir: std::path::PathBuf,
1633    #[serde(default, skip_serializing_if = "Option::is_none")]
1634    pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1635    #[serde(default)]
1636    pub render_status: RenderStatus,
1637}
1638
1639fn default_style_preset() -> String {
1640    "default-blob".into()
1641}
1642
1643fn default_expressions_dir() -> std::path::PathBuf {
1644    std::path::PathBuf::from("expressions")
1645}
1646
1647impl Default for AgentAppearance {
1648    fn default() -> Self {
1649        Self {
1650            style_preset: default_style_preset(),
1651            behavior_preset: BehaviorPreset::Normal,
1652            source_image_path: None,
1653            expressions_dir: default_expressions_dir(),
1654            last_rendered_at: None,
1655            render_status: RenderStatus::Pending,
1656        }
1657    }
1658}
1659
1660#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1661#[serde(rename_all = "snake_case")]
1662pub enum BehaviorPreset {
1663    Quiet,
1664    #[default]
1665    Normal,
1666    Lively,
1667}
1668
1669#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1670#[serde(tag = "status", rename_all = "snake_case")]
1671pub enum RenderStatus {
1672    #[default]
1673    Pending,
1674    Rendering {
1675        done: u8,
1676        total: u8,
1677    },
1678    Ready,
1679    Failed {
1680        reason: String,
1681    },
1682}
1683
1684// ──────────────────────────────────────────────────────────────────────────
1685// E6 — Agent Pattern Federation types
1686// ──────────────────────────────────────────────────────────────────────────
1687
1688/// When the agent pulls an updated pattern snapshot from the daemon.
1689#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1690#[serde(rename_all = "kebab-case")]
1691pub enum SnapshotPolicy {
1692    #[default]
1693    PullOnStart,
1694    PullPeriodic,
1695    Manual,
1696}
1697
1698/// Filter criteria for the pattern snapshot written to the agent's patterns_cache.
1699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1700pub struct PatternFilter {
1701    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1702    pub applies_in: Vec<String>,
1703    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1704    pub tier: Vec<String>,
1705    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1706    pub maturity: Vec<String>,
1707    #[serde(default)]
1708    pub importance_min: f64,
1709    #[serde(default = "default_max_snapshot_count")]
1710    pub max_count: usize,
1711    #[serde(default)]
1712    pub snapshot_policy: SnapshotPolicy,
1713}
1714
1715fn default_max_snapshot_count() -> usize {
1716    200
1717}
1718
1719impl Default for PatternFilter {
1720    fn default() -> Self {
1721        Self {
1722            applies_in: vec![],
1723            tier: vec![],
1724            maturity: vec![],
1725            importance_min: 0.0,
1726            max_count: 200,
1727            snapshot_policy: SnapshotPolicy::default(),
1728        }
1729    }
1730}
1731
1732/// Points to the knowledge-layer commit this agent's patterns_cache was built from.
1733#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1734pub struct SnapshotRef {
1735    pub knowledge_commit: String,
1736    pub taken_at: String,
1737    pub filter: PatternFilter,
1738}
1739
1740/// Federation configuration embedded in AgentProfile (E6).
1741#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1742pub struct FederationConfig {
1743    #[serde(default)]
1744    pub filter: PatternFilter,
1745    #[serde(default, skip_serializing_if = "Option::is_none")]
1746    pub snapshot_ref: Option<SnapshotRef>,
1747    #[serde(default)]
1748    pub evidence_flush_interval_minutes: u32,
1749}
1750
1751impl AgentProfile {
1752    /// Minimal valid profile for tests — no voice, no MCP, no skills.
1753    ///
1754    /// Available in all compilation modes so integration tests in
1755    /// dependent crates can call it (unlike `#[cfg(test)]` items which
1756    /// are invisible to downstream test binaries).
1757    #[doc(hidden)]
1758    pub fn default_for_tests() -> Self {
1759        serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1760            .expect("minimal profile fixture")
1761    }
1762
1763    /// This agent's Smart override, wherever it lives: the promoted `smart`
1764    /// field, else the legacy `routing.smart` nesting that older profiles and
1765    /// exported `.muragent` bundles still carry. `None` = follow the global
1766    /// setting.
1767    ///
1768    /// Every reader goes through here. A surface that checked only the
1769    /// promoted field would report "follows global" for an agent whose legacy
1770    /// override is actually in force — one fact, two answers.
1771    pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
1772        self.smart
1773            .as_ref()
1774            .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
1775    }
1776
1777    /// This agent's effective Smart config: the global values with the agent's
1778    /// override layered on.
1779    pub fn effective_smart(
1780        &self,
1781        cfg: &crate::config::ModelSwitchConfig,
1782    ) -> crate::config::SmartConfig {
1783        cfg.smart.merged(self.smart_override())
1784    }
1785
1786    /// This agent's effective difficulty-routing config.
1787    pub fn effective_routing(
1788        &self,
1789        cfg: &crate::config::ModelSwitchConfig,
1790    ) -> crate::config::RoutingConfig {
1791        cfg.routing.merged(self.routing.as_ref())
1792    }
1793
1794    /// Load an agent's profile from `<mur_home>/agents/<name>/profile.yaml`.
1795    ///
1796    /// Canonical read-path counterpart to the atomic-write path used by
1797    /// `mur agent create`/`mur agent mcp add` (`write_atomic` in
1798    /// `mur-core::cmd::agent`) — callers that already have `mur_home` in
1799    /// hand (e.g. provisioning flows, tests) can load a profile without
1800    /// going through the `MUR_HOME`-env-var-based `resolve_mur_home`.
1801    pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1802        let path = mur_home.join("agents").join(name).join("profile.yaml");
1803        let yaml = std::fs::read_to_string(&path)
1804            .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1805        serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1806    }
1807
1808    /// The imported add-on group a skill/mcp/command name belongs to.
1809    pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1810        self.addons.iter().find(|g| {
1811            g.skills.iter().any(|n| n == name)
1812                || g.mcp.iter().any(|n| n == name)
1813                || g.commands.iter().any(|n| n == name)
1814        })
1815    }
1816
1817    /// Whether `skill_name` is enabled (§3.3): not denied AND, if it
1818    /// belongs to an imported group, that group is enabled.
1819    pub fn skill_enabled(&self, skill_name: &str) -> bool {
1820        name_enabled(&self.disabled_skills, skill_name)
1821            && self.group_of(skill_name).is_none_or(|g| g.enabled)
1822    }
1823
1824    /// Whether MCP server `server_id` is enabled (§3.3).
1825    pub fn mcp_enabled(&self, server_id: &str) -> bool {
1826        name_enabled(&self.disabled_mcp, server_id)
1827            && self.group_of(server_id).is_none_or(|g| g.enabled)
1828    }
1829
1830    /// Toggle a skill for this agent without uninstalling it.
1831    pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1832        set_denylist(&mut self.disabled_skills, skill_name, enabled);
1833    }
1834
1835    /// Toggle an MCP server for this agent without removing it.
1836    pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1837        set_denylist(&mut self.disabled_mcp, server_id, enabled);
1838    }
1839
1840    /// Toggle an imported plugin-group as a unit. Returns false if no
1841    /// add-on has that id.
1842    pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1843        match self.addons.iter_mut().find(|g| g.id == addon_id) {
1844            Some(g) => {
1845                g.enabled = enabled;
1846                true
1847            }
1848            None => false,
1849        }
1850    }
1851
1852    /// Emergency kill-switch (§7): clears every add-on group's `enabled` flag.
1853    /// Members are already forced off by the group AND-gate in `skill_enabled` /
1854    /// `mcp_enabled`, so no denylist push is needed — and avoiding it means
1855    /// `set_addon_enabled(id, true)` fully restores the group without leftover
1856    /// per-member denials.
1857    pub fn disable_all_addons(&mut self) {
1858        for g in &mut self.addons {
1859            g.enabled = false;
1860        }
1861    }
1862
1863    /// This agent's MCP servers minus any disabled for it.
1864    pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1865        self.mcp_servers
1866            .iter()
1867            .filter(|m| self.mcp_enabled(&m.name))
1868            .cloned()
1869            .collect()
1870    }
1871}
1872
1873#[cfg(test)]
1874mod tests {
1875    /// Order must not matter: the same grants written in a different order are
1876    /// the same grants, and a digest that disagreed would report "restart to
1877    /// apply" after a cosmetic profile edit.
1878    #[test]
1879    fn grants_digest_ignores_order_but_not_content() {
1880        let a = FilesystemEntitlement {
1881            read: vec!["/a".into(), "/b".into()],
1882            write: vec!["/w".into()],
1883            deny: vec![],
1884        };
1885        let reordered = FilesystemEntitlement {
1886            read: vec!["/b".into(), "/a".into()],
1887            ..a.clone()
1888        };
1889        let changed = FilesystemEntitlement {
1890            write: vec!["/w".into(), "/x".into()],
1891            ..a.clone()
1892        };
1893        assert_eq!(
1894            filesystem_grants_digest(&a),
1895            filesystem_grants_digest(&reordered)
1896        );
1897        assert_ne!(
1898            filesystem_grants_digest(&a),
1899            filesystem_grants_digest(&changed)
1900        );
1901    }
1902
1903    /// A read grant and a write grant for the same path are different grants.
1904    #[test]
1905    fn grants_digest_separates_the_verbs() {
1906        let r = FilesystemEntitlement {
1907            read: vec!["/p".into()],
1908            write: vec![],
1909            deny: vec![],
1910        };
1911        let w = FilesystemEntitlement {
1912            read: vec![],
1913            write: vec!["/p".into()],
1914            deny: vec![],
1915        };
1916        assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
1917    }
1918
1919    /// A lock written before this field existed must still load — every agent
1920    /// running at upgrade time wrote one.
1921    #[test]
1922    fn a_lock_without_the_sandbox_block_still_deserialises() {
1923        let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
1924            "started_at":"t","binary_version":"v",
1925            "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
1926        let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
1927        assert!(lf.sandbox.is_none());
1928    }
1929
1930    #[test]
1931    fn the_sandbox_block_round_trips() {
1932        let rec = SandboxRecord {
1933            enforcing: false,
1934            mode: "advisory-only".into(),
1935            granted_digest: "sha256:x".into(),
1936            dropped: vec![DroppedGrant {
1937                path: "/gone".into(),
1938                verb: "write".into(),
1939                reason: "path does not exist on disk".into(),
1940            }],
1941        };
1942        let back: SandboxRecord =
1943            serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
1944        assert_eq!(back, rec);
1945    }
1946
1947    use super::*;
1948
1949    #[test]
1950    fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1951        let net = McpServerNetwork {
1952            mode: McpNetMode::BroadAudited,
1953            allow_hosts: vec![],
1954            deny_hosts: vec!["evil.example".into()],
1955            authorization: Some(EgressAuthorization {
1956                authorized_by: "david".into(),
1957                authorized_at_ms: 1_750_000_000_000,
1958            }),
1959        };
1960        let y = serde_yaml::to_string(&net).unwrap();
1961        assert!(y.contains("broad_audited"));
1962        let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1963        assert_eq!(back, net);
1964        // legacy per-server policy without the new fields still parses (serde default)
1965        let legacy: McpServerNetwork =
1966            serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1967        assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1968        assert!(legacy.authorization.is_none());
1969    }
1970
1971    #[test]
1972    fn mcp_entry_network_is_optional_and_round_trips() {
1973        // Absent in YAML → None (every existing profile keeps working).
1974        let bare = "name: x\ncommand: npx\n";
1975        let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1976        assert!(e.network.is_none());
1977
1978        // Present → parsed.
1979        let with = "name: browser\ncommand: npx\nnetwork:\n  mode: restricted\n  allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1980        let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1981        let net = e2.network.expect("network present");
1982        assert_eq!(net.mode, McpNetMode::Restricted);
1983        assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1984
1985        // Round-trip keeps None out of the serialized form.
1986        let out = serde_yaml_ng::to_string(&e).unwrap();
1987        assert!(!out.contains("network"));
1988    }
1989
1990    #[test]
1991    fn profile_round_trip_yaml() {
1992        let yaml = r#"
1993schema: 1
1994id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1995name: agent_a
1996display_name: "Price Hunter"
1997version: "0.1.0"
1998persona:
1999  category: research
2000  description: "Finds prices"
2001  traits: { tone: concise, risk: cautious, verbosity: low }
2002sys_prompt_file: "sys_prompt.md"
2003model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
2004mcp_servers: []
2005skills: []
2006transport:
2007  stdio: true
2008  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
2009communication: { accepts_from: ["*"], sends_to: [] }
2010capabilities: ["a2a.message.send", "a2a.tasks"]
2011entitlements:
2012  network:
2013    inbound: { ports: [] }
2014    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
2015  filesystem: { read: [], write: [], deny: [] }
2016  processes: { spawn: { mode: allowlist, allowed: [] } }
2017  syscalls: { mode: default }
2018  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
2019notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
2020retry:
2021  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
2022  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
2023lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
2024created_at: "2026-04-22T10:00:00+08:00"
2025updated_at: "2026-04-22T10:00:00+08:00"
2026"#;
2027        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
2028        assert_eq!(profile.name, "agent_a");
2029        assert_eq!(profile.persona.category, PersonaCategory::Research);
2030        assert_eq!(
2031            profile.entitlements.network.outbound.mode,
2032            NetworkOutboundMode::Restricted
2033        );
2034        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
2035        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
2036        assert_eq!(profile.id, round_tripped.id);
2037    }
2038
2039    #[test]
2040    fn requires_capabilities_defaults_empty_and_round_trips() {
2041        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2042        let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
2043        assert!(p.requires_capabilities.is_empty());
2044        let with = format!("{base}\nrequires_capabilities:\n  - media\n");
2045        let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
2046        assert_eq!(p2.requires_capabilities, vec!["media"]);
2047    }
2048}
2049
2050#[cfg(test)]
2051mod model_ref_tests {
2052    use super::*;
2053
2054    #[test]
2055    fn legacy_profile_without_model_ref_still_parses() {
2056        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2057        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2058        assert!(
2059            p.model_ref.is_none(),
2060            "legacy profile must not have model_ref"
2061        );
2062    }
2063
2064    #[test]
2065    fn round_trip_with_model_ref_preserves_field() {
2066        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2067        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2068        p.model_ref = Some("anthropic_opus_4_7".into());
2069        let s = serde_yaml_ng::to_string(&p).unwrap();
2070        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
2071        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2072        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
2073    }
2074
2075    #[test]
2076    fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
2077        // Load fixture (no fallback_chain / routing) — legacy safe.
2078        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2079        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2080        assert!(
2081            p.fallback_chain.is_empty(),
2082            "legacy profile must have empty fallback_chain"
2083        );
2084        assert!(
2085            p.routing.is_none(),
2086            "legacy profile must have no routing override"
2087        );
2088
2089        // Round-trip with fallback_chain and routing.
2090        let mut p = p.clone();
2091        p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
2092        p.routing = Some(crate::config::RoutingOverride {
2093            enabled: Some(true),
2094            ..Default::default()
2095        });
2096        let s = serde_yaml_ng::to_string(&p).unwrap();
2097        assert!(
2098            s.contains("fallback_chain:"),
2099            "yaml must contain fallback_chain"
2100        );
2101        assert!(s.contains("routing:"), "yaml must contain routing");
2102        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2103        assert_eq!(
2104            p2.fallback_chain,
2105            vec!["claude_opus", "claude_sonnet"],
2106            "fallback_chain must round-trip"
2107        );
2108        assert_eq!(
2109            p2.routing.as_ref().unwrap().enabled,
2110            Some(true),
2111            "routing.enabled must round-trip"
2112        );
2113    }
2114
2115    #[test]
2116    fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
2117        use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
2118        let cfg = ModelSwitchConfig {
2119            smart: SmartConfig {
2120                enabled: false,
2121                cheap: Some("g".into()),
2122                max_escalations: 2,
2123            },
2124            ..Default::default()
2125        };
2126        // No override at all → the global values, untouched.
2127        let p = AgentProfile::default_for_tests();
2128        assert_eq!(p.effective_smart(&cfg), cfg.smart);
2129
2130        // Legacy profiles carry the override nested under `routing`.
2131        let mut legacy = AgentProfile::default_for_tests();
2132        legacy.routing = Some(crate::config::RoutingOverride {
2133            smart: Some(SmartOverride {
2134                enabled: Some(true),
2135                ..Default::default()
2136            }),
2137            ..Default::default()
2138        });
2139        assert!(
2140            legacy.effective_smart(&cfg).enabled,
2141            "legacy nesting is read"
2142        );
2143        assert_eq!(
2144            legacy.effective_smart(&cfg).cheap.as_deref(),
2145            Some("g"),
2146            "unset fields still inherit"
2147        );
2148
2149        // The promoted field wins when both are present.
2150        let mut both = legacy.clone();
2151        both.smart = Some(SmartOverride {
2152            enabled: Some(false),
2153            ..Default::default()
2154        });
2155        assert!(!both.effective_smart(&cfg).enabled);
2156    }
2157}
2158
2159/// GUI-facing reification of the companion's three-layer permission toggle.
2160///
2161/// On-disk schema doesn't change — this helper just maps between the
2162/// three independent booleans (`enabled`, `rhythm.enabled`,
2163/// `proactive.enabled`) and a single ordered tier. Use
2164/// [`ProactiveTier::from_config`] to read and [`ProactiveTier::apply`]
2165/// to write.
2166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2167#[serde(rename_all = "snake_case")]
2168pub enum ProactiveTier {
2169    Off,
2170    WarmOnly,
2171    WarmAndBehavior,
2172    All,
2173}
2174
2175impl ProactiveTier {
2176    pub fn from_config(c: &CompanionConfig) -> Self {
2177        match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
2178            (false, _, _) => Self::Off,
2179            (true, false, false) => Self::WarmOnly,
2180            (true, true, false) => Self::WarmAndBehavior,
2181            (true, _, true) => Self::All,
2182        }
2183    }
2184
2185    pub fn apply(&self, c: &mut CompanionConfig) {
2186        match self {
2187            Self::Off => {
2188                c.enabled = false;
2189                c.rhythm.enabled = false;
2190                c.proactive.enabled = false;
2191            }
2192            Self::WarmOnly => {
2193                c.enabled = true;
2194                c.rhythm.enabled = false;
2195                c.proactive.enabled = false;
2196            }
2197            Self::WarmAndBehavior => {
2198                c.enabled = true;
2199                c.rhythm.enabled = true;
2200                c.proactive.enabled = false;
2201            }
2202            Self::All => {
2203                c.enabled = true;
2204                c.rhythm.enabled = true;
2205                c.proactive.enabled = true;
2206            }
2207        }
2208    }
2209}
2210
2211#[cfg(test)]
2212mod mcp_pin_tests {
2213    use super::*;
2214
2215    /// Pre-M9 profiles must continue to deserialize with the new
2216    /// optional fields absent. Round-trip: serialize back out and
2217    /// confirm the optional fields don't leak into the YAML.
2218    #[test]
2219    fn pre_m9_entry_roundtrips_without_pin_fields() {
2220        let yaml = r#"
2221name: weather
2222command: /opt/mcp/weather
2223args: ["--port", "0"]
2224"#;
2225        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2226        assert_eq!(entry.name, "weather");
2227        assert_eq!(entry.binary_sha256, None);
2228        assert_eq!(entry.description_hash, None);
2229        assert_eq!(entry.publisher, None);
2230        assert_eq!(entry.installed_at, None);
2231
2232        // skip_serializing_if = "Option::is_none" must keep the YAML
2233        // free of empty pin fields when the entry is pre-M9.
2234        let out = serde_yaml_ng::to_string(&entry).unwrap();
2235        assert!(!out.contains("binary_sha256"), "got {out}");
2236        assert!(!out.contains("description_hash"), "got {out}");
2237        assert!(!out.contains("publisher"), "got {out}");
2238        assert!(!out.contains("installed_at"), "got {out}");
2239    }
2240
2241    /// Full M9 entry with all fields set round-trips losslessly.
2242    #[test]
2243    fn full_m9_entry_roundtrips_all_fields() {
2244        let yaml = r#"
2245name: weather
2246command: /opt/mcp/weather
2247args: []
2248binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
2249description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
2250publisher:
2251  name: "@anthropic-mcp/weather"
2252  homepage: "https://github.com/anthropic-mcp/weather"
2253  registry_id: "@anthropic-mcp/weather@1.2.3"
2254installed_at: "2026-05-06T08:00:00Z"
2255"#;
2256        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2257        assert!(
2258            entry
2259                .binary_sha256
2260                .as_deref()
2261                .unwrap()
2262                .starts_with("3f4abca8")
2263        );
2264        assert!(
2265            entry
2266                .description_hash
2267                .as_deref()
2268                .unwrap()
2269                .starts_with("9a01b2c3")
2270        );
2271        let pub_info = entry.publisher.clone().unwrap();
2272        assert_eq!(pub_info.name, "@anthropic-mcp/weather");
2273        assert_eq!(
2274            pub_info.homepage.as_deref(),
2275            Some("https://github.com/anthropic-mcp/weather"),
2276        );
2277        assert_eq!(
2278            pub_info.registry_id.as_deref(),
2279            Some("@anthropic-mcp/weather@1.2.3"),
2280        );
2281        let installed = entry.installed_at.unwrap();
2282        assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
2283    }
2284
2285    /// Partial — only the binary hash is set (e.g. probe failed but
2286    /// install proceeded). The supervisor still needs to be able to
2287    /// deserialize this without panicking.
2288    #[test]
2289    fn partial_pin_only_binary_sha_roundtrips() {
2290        let yaml = r#"
2291name: weather
2292command: /opt/mcp/weather
2293args: []
2294binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
2295"#;
2296        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2297        assert_eq!(
2298            entry.binary_sha256.as_deref(),
2299            Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
2300        );
2301        assert_eq!(entry.description_hash, None);
2302        assert_eq!(entry.publisher, None);
2303    }
2304
2305    /// Publisher with only the required `name` field — homepage and
2306    /// registry_id are optional.
2307    #[test]
2308    fn publisher_minimal_just_name() {
2309        let yaml = r#"
2310name: weather
2311command: /opt/mcp/weather
2312args: []
2313publisher:
2314  name: "alice"
2315"#;
2316        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2317        let p = entry.publisher.as_ref().unwrap();
2318        assert_eq!(p.name, "alice");
2319        assert_eq!(p.homepage, None);
2320        assert_eq!(p.registry_id, None);
2321
2322        // skip_serializing_if must omit the optional sub-fields too.
2323        let out = serde_yaml_ng::to_string(&entry).unwrap();
2324        assert!(!out.contains("homepage:"), "got {out}");
2325        assert!(!out.contains("registry_id:"), "got {out}");
2326    }
2327}
2328
2329#[cfg(test)]
2330mod voice_tests {
2331    use super::*;
2332    use std::str::FromStr;
2333
2334    #[test]
2335    fn voice_config_round_trips() {
2336        // Base: use the canonical minimal fixture and append a voice: block.
2337        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2338        let yaml = format!("{base}voice:\n  enabled: true\n  voice_id: af_bella\n");
2339
2340        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
2341        assert!(profile.voice.enabled);
2342        assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
2343
2344        // Legacy profiles (no voice: block) must still load.
2345        let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
2346        assert!(!legacy.voice.enabled);
2347        assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
2348    }
2349
2350    #[test]
2351    fn voice_id_from_str_roundtrips() {
2352        let cases = [
2353            ("af_heart", VoiceId::AfHeart),
2354            ("af_bella", VoiceId::AfBella),
2355            ("af_nicole", VoiceId::AfNicole),
2356            ("am_adam", VoiceId::AmAdam),
2357            ("am_michael", VoiceId::AmMichael),
2358        ];
2359        for (s, expected) in cases {
2360            assert_eq!(VoiceId::from_str(s).unwrap(), expected);
2361            assert_eq!(expected.as_str(), s);
2362        }
2363    }
2364
2365    #[test]
2366    fn voice_id_from_str_rejects_unknown() {
2367        assert!(VoiceId::from_str("bogus").is_err());
2368    }
2369}
2370
2371#[cfg(test)]
2372mod idle_trigger_tests {
2373    use super::*;
2374
2375    #[test]
2376    fn idle_trigger_yaml_round_trip() {
2377        let yaml = r#"
2378restart: on_failure
2379idle_triggers:
2380  - after_secs: 3600
2381    message: "still there?"
2382    sends_to: other_agent
2383    cooldown_secs: 1800
2384    respect_quiet_hours: true
2385"#;
2386        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2387        assert_eq!(cfg.idle_triggers.len(), 1);
2388        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
2389        assert_eq!(cfg.idle_triggers[0].message, "still there?");
2390        assert_eq!(
2391            cfg.idle_triggers[0].sends_to.as_deref(),
2392            Some("other_agent")
2393        );
2394        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
2395        assert!(cfg.idle_triggers[0].respect_quiet_hours);
2396    }
2397
2398    #[test]
2399    fn idle_trigger_defaults_when_omitted() {
2400        let yaml = "restart: on_failure\n";
2401        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2402        assert!(cfg.idle_triggers.is_empty());
2403    }
2404}
2405
2406#[cfg(test)]
2407mod appearance_tests {
2408    use super::*;
2409
2410    #[test]
2411    fn appearance_default_style_preset_is_default_blob() {
2412        assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2413    }
2414
2415    #[test]
2416    fn appearance_default_behavior_is_normal() {
2417        assert_eq!(
2418            AgentAppearance::default().behavior_preset,
2419            BehaviorPreset::Normal
2420        );
2421    }
2422
2423    #[test]
2424    fn appearance_default_render_status_is_pending() {
2425        assert_eq!(
2426            AgentAppearance::default().render_status,
2427            RenderStatus::Pending
2428        );
2429    }
2430
2431    #[test]
2432    fn render_status_serde_round_trip() {
2433        let cases = [
2434            RenderStatus::Pending,
2435            RenderStatus::Rendering { done: 3, total: 12 },
2436            RenderStatus::Ready,
2437            RenderStatus::Failed {
2438                reason: "out of quota".into(),
2439            },
2440        ];
2441        for status in cases {
2442            let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2443            let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2444            assert_eq!(status, back);
2445        }
2446    }
2447
2448    #[test]
2449    fn agent_profile_with_appearance_round_trips() {
2450        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2451        let yaml = format!(
2452            "{base}appearance:\n  style_preset: chiikawa\n  render_status:\n    status: ready\n"
2453        );
2454        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2455        assert_eq!(profile.appearance.style_preset, "chiikawa");
2456        assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2457
2458        let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2459        let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2460        assert_eq!(profile.appearance, back.appearance);
2461    }
2462
2463    #[test]
2464    fn legacy_profile_without_appearance_uses_default() {
2465        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2466        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2467        assert_eq!(profile.appearance.style_preset, "default-blob");
2468        assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2469        assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2470    }
2471
2472    #[test]
2473    fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2474        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2475        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2476        assert!(p.file_actions.is_empty());
2477        assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2478        assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2479    }
2480}
2481
2482#[cfg(test)]
2483mod federation_tests {
2484    use super::*;
2485
2486    #[test]
2487    fn test_pattern_filter_default() {
2488        let f = PatternFilter::default();
2489        assert_eq!(f.max_count, 200);
2490        assert_eq!(f.importance_min, 0.0);
2491        assert!(f.tier.is_empty());
2492    }
2493
2494    #[test]
2495    fn test_federation_config_roundtrip() {
2496        let cfg = FederationConfig {
2497            filter: PatternFilter {
2498                tier: vec!["core".into()],
2499                max_count: 50,
2500                ..Default::default()
2501            },
2502            snapshot_ref: Some(SnapshotRef {
2503                knowledge_commit: "abc123def456".into(),
2504                taken_at: "2026-05-19T00:00:00Z".into(),
2505                filter: PatternFilter::default(),
2506            }),
2507            evidence_flush_interval_minutes: 15,
2508        };
2509        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2510        let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2511        assert_eq!(cfg, back);
2512    }
2513
2514    #[test]
2515    fn test_agent_profile_federation_defaults() {
2516        // AgentProfile without a federation block deserializes with FederationConfig::default().
2517        // Use the minimal YAML that passes validation — just the required fields.
2518        // (We check only that the field has its zero value, not full profile parse.)
2519        let cfg = FederationConfig::default();
2520        assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2521        assert!(cfg.snapshot_ref.is_none());
2522    }
2523}
2524
2525#[cfg(test)]
2526mod skill_card_tests {
2527    use super::*;
2528
2529    #[test]
2530    fn installed_skills_default_to_empty_when_absent() {
2531        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2532        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2533        assert!(p.installed_skills.is_empty());
2534    }
2535
2536    #[test]
2537    fn installed_skills_roundtrip_preserves_entries() {
2538        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2539        let yaml = format!(
2540            "{base}installed_skills:\n  - name: s1\n    version: 1.0.0\n    publisher: human:d\n    description: desc\n    category: workflow\n    tags: [web]\n    triggers:\n      - type: command\n        pattern: /find\n    abstract: does things\n    transfer_chain:\n      - agent://alice\n"
2541        );
2542        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2543        assert_eq!(p.installed_skills.len(), 1);
2544        assert_eq!(p.installed_skills[0].name, "s1");
2545        assert_eq!(p.installed_skills[0].abstract_text, "does things");
2546        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2547
2548        let out = serde_yaml_ng::to_string(&p).unwrap();
2549        assert!(out.contains("abstract: does things"));
2550        assert!(out.contains("pattern: /find"));
2551
2552        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2553        assert_eq!(p.installed_skills, back.installed_skills);
2554    }
2555
2556    #[test]
2557    fn installed_skills_minimal_entry_serializes_compactly() {
2558        // A name-only entry must NOT emit empty string fields.
2559        let entry = SkillCardEntry {
2560            name: "minimal".into(),
2561            ..Default::default()
2562        };
2563        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2564        assert!(yaml.contains("name: minimal"));
2565        assert!(
2566            !yaml.contains("version:"),
2567            "empty version must be skipped: {yaml}"
2568        );
2569        assert!(
2570            !yaml.contains("publisher:"),
2571            "empty publisher must be skipped: {yaml}"
2572        );
2573        assert!(
2574            !yaml.contains("abstract:"),
2575            "empty abstract must be skipped: {yaml}"
2576        );
2577    }
2578}
2579
2580#[cfg(test)]
2581mod tool_policy_tests {
2582    use super::*;
2583
2584    fn rules() -> Vec<ToolRule> {
2585        vec![
2586            ToolRule {
2587                pattern: "mcp__github__merge_pr".into(),
2588                policy: ToolPolicy::Ask,
2589                risk: None,
2590            },
2591            ToolRule {
2592                pattern: "mcp__github__*".into(),
2593                policy: ToolPolicy::Allow,
2594                risk: None,
2595            },
2596            ToolRule {
2597                pattern: "mcp__*".into(),
2598                policy: ToolPolicy::Deny,
2599                risk: None,
2600            },
2601            ToolRule {
2602                pattern: "bash".into(),
2603                policy: ToolPolicy::Allow,
2604                risk: None,
2605            },
2606        ]
2607    }
2608
2609    #[test]
2610    fn exact_beats_glob() {
2611        assert_eq!(
2612            resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2613            ToolPolicy::Ask
2614        );
2615    }
2616
2617    #[test]
2618    fn longer_glob_wins() {
2619        assert_eq!(
2620            resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2621            ToolPolicy::Allow
2622        );
2623    }
2624
2625    #[test]
2626    fn shorter_glob_fallback() {
2627        assert_eq!(
2628            resolve_tool_policy(&rules(), "mcp__slack__send"),
2629            ToolPolicy::Deny
2630        );
2631    }
2632
2633    #[test]
2634    fn exact_bash() {
2635        assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2636    }
2637
2638    #[test]
2639    fn unknown_tool_defaults_ask() {
2640        assert_eq!(
2641            resolve_tool_policy(&rules(), "unknown_tool"),
2642            ToolPolicy::Ask
2643        );
2644    }
2645
2646    #[test]
2647    fn empty_rules_defaults_ask() {
2648        assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2649    }
2650
2651    fn minimal_entitlements_yaml() -> &'static str {
2652        "network:\n  inbound: {}\n  outbound:\n    mode: off\nfilesystem: {}\nprocesses:\n  spawn:\n    mode: none\n"
2653    }
2654
2655    #[test]
2656    fn entitlements_tools_defaults_empty() {
2657        let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2658        assert!(e.tools.is_empty());
2659    }
2660
2661    #[test]
2662    fn entitlements_tools_roundtrip() {
2663        let base = minimal_entitlements_yaml();
2664        let yaml = format!("{base}tools:\n  - pattern: \"mcp__github__*\"\n    policy: allow\n");
2665        let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2666        assert_eq!(e.tools.len(), 1);
2667        assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2668        let y = serde_yaml_ng::to_string(&e).unwrap();
2669        let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2670        assert_eq!(back.tools.len(), 1);
2671        assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2672    }
2673    #[test]
2674    fn denylist_membership_and_mutation() {
2675        let mut list: Vec<String> = vec![];
2676        assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2677
2678        set_denylist(&mut list, "a", false); // disable
2679        assert!(!name_enabled(&list, "a"));
2680        assert_eq!(list, ["a"]);
2681
2682        set_denylist(&mut list, "a", false); // idempotent disable
2683        assert_eq!(list, ["a"], "no duplicate entries");
2684
2685        set_denylist(&mut list, "a", true); // enable removes
2686        assert!(name_enabled(&list, "a"));
2687        assert!(list.is_empty());
2688
2689        set_denylist(&mut list, "b", true); // enabling an absent name is a no-op
2690        assert!(list.is_empty());
2691    }
2692
2693    #[test]
2694    fn addon_group_rule_truth_table() {
2695        let mut p = AgentProfile::default_for_tests();
2696        p.addons.push(AddonRef {
2697            id: "grp".into(),
2698            source: "claude-local:grp@1.0.0".into(),
2699            enabled: false,
2700            skills: vec!["g_skill".into()],
2701            mcp: vec!["g_mcp".into()],
2702            commands: vec!["g_cmd".into()],
2703            content_hash: None,
2704            fetch_ref: None,
2705            fetch_plugin: None,
2706        });
2707
2708        // 1. standalone item, no entry anywhere => enabled (back-compat)
2709        assert!(p.skill_enabled("standalone"));
2710        assert!(p.mcp_enabled("standalone_mcp"));
2711
2712        // 2. grouped item, group disabled => off (cannot enable one member of a disabled group)
2713        assert!(!p.skill_enabled("g_skill"));
2714        assert!(!p.mcp_enabled("g_mcp"));
2715
2716        // 3. grouped item, group enabled, name not denied => on
2717        assert!(p.set_addon_enabled("grp", true));
2718        assert!(p.skill_enabled("g_skill"));
2719        assert!(p.mcp_enabled("g_mcp"));
2720
2721        // 4. name in denylist overrides an enabled group => off (silence one member)
2722        p.set_skill_enabled("g_skill", false);
2723        assert!(!p.skill_enabled("g_skill"));
2724
2725        // set_addon_enabled on a missing id reports false
2726        assert!(!p.set_addon_enabled("nope", true));
2727
2728        // kill-switch: only flips group flags — no denylist push
2729        p.disable_all_addons();
2730        assert!(p.addons.iter().all(|g| !g.enabled));
2731        assert!(!p.skill_enabled("g_skill"));
2732        assert!(!p.skill_enabled("g_cmd"));
2733        assert!(!p.mcp_enabled("g_mcp")); // mcp kill-switch asserted
2734
2735        // re-enable restores members — kill-switch is NOT sticky
2736        // (g_skill was individually denied in step 4 above and stays off;
2737        //  g_cmd and g_mcp were never individually denied so they come back on)
2738        assert!(p.set_addon_enabled("grp", true));
2739        assert!(!p.skill_enabled("g_skill")); // still individually denied from step 4
2740        assert!(p.skill_enabled("g_cmd")); // restored: never individually denied
2741        assert!(p.mcp_enabled("g_mcp")); // restored: never individually denied
2742
2743        // clearing the individual deny fully restores g_skill too
2744        p.set_skill_enabled("g_skill", true);
2745        assert!(p.skill_enabled("g_skill"));
2746    }
2747
2748    #[test]
2749    fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2750        // legacy AddonRef (no new fields) → None
2751        let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2752        let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2753        assert_eq!(r.content_hash, None);
2754        assert_eq!(r.fetch_ref, None);
2755
2756        // with the new fields → round-trips
2757        let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2758        let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2759        assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2760        assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2761        let back = serde_yaml_ng::to_string(&r2).unwrap();
2762        let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2763        assert_eq!(r2, r3);
2764    }
2765}
2766
2767#[cfg(test)]
2768mod lockfile_compat_tests {
2769    use super::*;
2770
2771    #[test]
2772    fn lockfile_new_fields_default_for_old_locks() {
2773        // An old lock JSON without build_sha/proto_version must still parse,
2774        // defaulting to "" / 0 (= "predates this feature → stale/unsupported").
2775        let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2776          "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2777          "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2778        let lock: LockFile = serde_json::from_str(old).unwrap();
2779        assert_eq!(lock.build_sha, "");
2780        assert_eq!(lock.proto_version, 0);
2781    }
2782}
2783
2784#[cfg(test)]
2785mod remote_mcp_tests {
2786    use super::*;
2787
2788    #[test]
2789    fn mcp_entry_roundtrips_remote_bearer() {
2790        let e = McpServerEntry {
2791            name: "gh".into(),
2792            command: String::new(),
2793            url: Some("https://api.example.com/mcp".into()),
2794            auth: Some(McpAuth::Bearer {
2795                token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2796            }),
2797            ..Default::default()
2798        };
2799        let y = serde_yaml_ng::to_string(&e).unwrap();
2800        let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2801        assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2802        assert!(matches!(
2803            back.auth,
2804            Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2805        ));
2806        // A legacy stdio entry (no url/auth) still parses.
2807        let legacy: McpServerEntry =
2808            serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2809        assert!(legacy.url.is_none());
2810        assert!(legacy.auth.is_none());
2811    }
2812}
2813
2814#[cfg(test)]
2815mod requires_programs_tests {
2816    #[test]
2817    fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2818        let with = r#"
2819name: research-gateway
2820command: mur-research-gateway
2821requires_programs:
2822  - name: lightpanda
2823    detect: { file: "~/.mur/aura/lightpanda" }
2824    reason: "render tier"
2825    registry: lightpanda
2826"#;
2827        let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2828        assert_eq!(e.requires_programs.len(), 1);
2829        assert_eq!(e.requires_programs[0].name, "lightpanda");
2830
2831        // Absent block → empty (back-compat).
2832        let without = "name: x\ncommand: y\n";
2833        let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2834        assert!(e2.requires_programs.is_empty());
2835    }
2836}