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. Discovery/job-routing stays on the A2A card's
56    /// skills/tags; this is purely organizational.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub role: Option<String>,
59    pub version: String,
60    pub persona: Persona,
61    pub sys_prompt_file: String,
62    pub model: ModelConfig,
63    /// Optional pointer into ~/.mur/models.yaml. When set, the runtime
64    /// prefers the registry entry over the inline `model:` block.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub model_ref: Option<String>,
67    #[serde(default)]
68    pub mcp_servers: Vec<McpServerEntry>,
69    #[serde(default)]
70    pub skills: Vec<String>,
71    /// Skills installed via `mur skill install`. Distinct from `skills`
72    /// (which holds legacy per-agent paths from `mur agent skill add`).
73    /// Broadcast in the Agent Card alongside `skills`.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub installed_skills: Vec<SkillCardEntry>,
76    /// Per-agent skill denylist (add-on Phase 1). Skill names that are
77    /// installed/visible to this agent but suppressed from injection.
78    /// Non-destructive: the skill's files/stats are untouched. Empty = all
79    /// visible skills enabled (back-compat: absent in old profiles).
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub disabled_skills: Vec<String>,
82
83    /// Per-agent MCP denylist (add-on Phase 1). `McpServerEntry` names not
84    /// spawned for this agent. Non-destructive: the entry + its pin stay in
85    /// the profile. Empty = all configured servers enabled.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub disabled_mcp: Vec<String>,
88    /// Plugin-groups imported by this agent (add-on Phase 2). Each is
89    /// self-contained (members installed per-agent). Absent/empty in
90    /// legacy profiles (back-compat).
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub addons: Vec<AddonRef>,
93    pub transport: TransportConfig,
94    pub communication: CommunicationConfig,
95    #[serde(default)]
96    pub capabilities: Vec<String>,
97    pub entitlements: Entitlements,
98    #[serde(default)]
99    pub notifications: NotificationsConfig,
100    pub retry: RetryConfig,
101    pub lifecycle: LifecycleConfig,
102    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
103    /// (legacy P0a profiles continue to load without this block).
104    #[serde(default)]
105    pub identity: IdentityConfig,
106    #[serde(default)]
107    pub file_transfer: FileTransferConfig,
108    #[serde(default)]
109    pub deployment: DeploymentConfig,
110    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
111    /// continue to load without this block).
112    #[serde(default)]
113    pub companion: CompanionConfig,
114    /// Human-in-the-loop configuration (Phase 2). Default = disabled.
115    #[serde(default)]
116    pub hitl: HitlConfig,
117    /// Voice I/O configuration (D1). Default = disabled.
118    #[serde(default)]
119    pub voice: VoiceConfig,
120    /// A1: config-driven handler picker. Absent block = all defaults.
121    #[serde(default)]
122    pub hooks: crate::HooksConfig,
123    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
124    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
125    #[serde(default)]
126    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
127    pub created_at: String,
128    pub updated_at: String,
129    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
130    #[serde(default)]
131    pub appearance: AgentAppearance,
132    /// E6: Pattern federation — snapshot filter + outbox config.
133    #[serde(default)]
134    pub federation: FederationConfig,
135
136    /// A1: declarative UI action list — file_actions rendered as action
137    /// buttons in the pending-item selection UI. New top-level key; NOT
138    /// nested under `capabilities:`.
139    #[serde(default)]
140    pub file_actions: Vec<crate::action::FileAction>,
141
142    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
143    #[serde(default)]
144    pub action_pipeline: crate::action::ActionPipelineConfig,
145
146    /// External programs this artifact needs at runtime (portable-deps spec).
147    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub requires_programs: Vec<ProgramDep>,
150}
151
152fn default_algorithm() -> String {
153    "ed25519".into()
154}
155
156/// Algorithms the runtime can generate + verify.
157pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160pub struct IdentityConfig {
161    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
162    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
163    #[serde(default)]
164    pub pubkey: String,
165    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub owner: Option<String>,
168
169    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
170    /// Cryptographic algorithm for this key. Defaults to "ed25519".
171    #[serde(default = "default_algorithm")]
172    pub algorithm: String,
173    /// Monotonic version counter; 0 = initial create, increments on each rotation.
174    #[serde(default)]
175    pub key_version: u32,
176    /// RFC3339 timestamp of when this key was created.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub created_at_key: Option<String>,
179    /// Previous public key (before most recent rotation). None if not rotated yet.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub previous_pubkey: Option<String>,
182    /// Version of the previous key. None if not rotated yet.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub previous_key_version: Option<u32>,
185    /// RFC3339 timestamp when grace period expires and old key is fully retired.
186    /// Only set during rotation; cleared once grace period ends.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub grace_expires_at: Option<String>,
189    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub rotated_at: Option<String>,
192    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub emergency_rekey_at: Option<String>,
195}
196
197impl Default for IdentityConfig {
198    fn default() -> Self {
199        Self {
200            pubkey: String::new(),
201            owner: None,
202            algorithm: default_algorithm(),
203            key_version: 0,
204            created_at_key: None,
205            previous_pubkey: None,
206            previous_key_version: None,
207            grace_expires_at: None,
208            rotated_at: None,
209            emergency_rekey_at: None,
210        }
211    }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
215pub struct Persona {
216    pub category: PersonaCategory,
217    pub description: String,
218    pub traits: PersonaTraits,
219}
220
221#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
222#[serde(rename_all = "lowercase")]
223pub enum PersonaCategory {
224    Research,
225    Automation,
226    Monitor,
227    Notify,
228    Commerce,
229    Custom,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233pub struct PersonaTraits {
234    pub tone: String,
235    pub risk: String,
236    pub verbosity: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
240pub struct ModelConfig {
241    pub provider: String,
242    pub name: String,
243    #[serde(default)]
244    pub params: BTreeMap<String, serde_yaml_ng::Value>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
248pub struct McpServerEntry {
249    pub name: String,
250    pub command: String,
251    #[serde(default)]
252    pub args: Vec<String>,
253
254    /// SHA-256 (hex, lowercase) of the binary at `command`'s resolved
255    /// path, captured at install time. `None` means the entry was
256    /// added before B0 M9.1 (back-compat) and rule-6 enforcement is
257    /// not applied — the supervisor will warn but not block.
258    /// (B0 rule 6 / M9.1)
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub binary_sha256: Option<String>,
261
262    /// SHA-256 (hex, lowercase) of the canonical-JSON of the MCP's
263    /// `tools/list` response, captured at install time. `None` means
264    /// the install path skipped the description probe (e.g. the MCP
265    /// uses a non-stdio transport or the binary couldn't be reached)
266    /// or the entry pre-dates M9. (B0 rule 6 / M9.1)
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub description_hash: Option<String>,
269
270    /// Display-only publisher metadata captured at install time so
271    /// the user can recall what they consented to. `None` for older
272    /// entries. (B0 rule 6 / M9.1)
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub publisher: Option<McpPublisherInfo>,
275
276    /// RFC3339 timestamp of when the entry was added or last
277    /// re-approved by the user via `mur agent mcp pin`. Used by the
278    /// rug-pull dialog UX. `None` for older entries. (B0 rule 6 / M9.1)
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
281
282    /// Per-tool-call timeout for this server, in seconds. `None` uses the
283    /// runtime default. Slow tools (e.g. `video_analyze`: transcript fetch
284    /// + local-model map-reduce) need a longer budget than the default.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub timeout_secs: Option<u32>,
287
288    /// Per-server outbound egress override. `None` = inherit the agent-level
289    /// policy (default; unchanged behavior). `Restricted` routes this server's
290    /// child through the runtime egress proxy with `allow_hosts` (advisory).
291    /// See `docs/superpowers/plans/2026-06-26-mcp-per-server-egress.md`.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub network: Option<McpServerNetwork>,
294
295    /// HTTP(S) base URL for a remote (Streamable-HTTP or SSE) MCP server.
296    /// Mutually exclusive with `command` in practice; `None` = stdio transport.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub url: Option<String>,
299
300    /// Authentication credentials for a remote MCP server.
301    /// `None` = no auth (or stdio transport).
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub auth: Option<McpAuth>,
304
305    /// External programs this artifact needs at runtime (portable-deps spec).
306    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
307    #[serde(default, skip_serializing_if = "Vec::is_empty")]
308    pub requires_programs: Vec<ProgramDep>,
309}
310
311/// Authentication scheme for a remote (HTTP) MCP server.
312#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
313#[serde(rename_all = "snake_case", tag = "kind")]
314pub enum McpAuth {
315    /// Static bearer token stored as a secret reference.
316    Bearer { token: crate::secret::SecretRef },
317    /// OAuth 2.1 token, with dynamic client registration state.
318    Oauth(OauthAuth),
319}
320
321/// OAuth 2.1 state persisted alongside remote MCP entry.
322#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
323pub struct OauthAuth {
324    /// Authorization-server token endpoint (from discovery).
325    pub token_endpoint: String,
326    /// Client id from dynamic client registration.
327    pub client_id: String,
328    /// Keychain ref to access token.
329    pub access_token: crate::secret::SecretRef,
330    /// Keychain ref refresh token, if server issued one.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub refresh_token: Option<crate::secret::SecretRef>,
333    /// Unix-epoch seconds access token expires (0 = unknown).
334    #[serde(default)]
335    pub expires_at: u64,
336}
337
338/// How an MCP server's outbound network is scoped.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
340#[serde(rename_all = "snake_case")]
341pub enum McpNetMode {
342    /// Inherit the agent-level outbound policy (today's behavior). No proxy.
343    #[default]
344    Inherit,
345    /// Allow only `allow_hosts`, routed through the runtime egress proxy.
346    Restricted,
347    /// Allow ALL hosts EXCEPT `deny_hosts`, routed through the runtime egress
348    /// proxy, with every CONNECT audited. For trusted-but-broad tools (e.g. a
349    /// web-research browser) that cannot enumerate their destinations. Requires
350    /// explicit operator consent (records `authorization`); downgraded to
351    /// `Inherit` on import (lowest trust). Advisory enforcement (see egress_proxy).
352    BroadAudited,
353    /// No outbound for this server at all.
354    Off,
355}
356
357/// Env var name a sandboxed MCP child reads to self-enforce the operator's
358/// `deny_hosts` overlay on connections the egress proxy cannot observe (e.g.
359/// `mur-research-gateway`'s tier-2/3 browser subprocesses — the proxy only
360/// sees tier-1 `reqwest` traffic). `mur-agent-runtime`'s `proxy_env_for` sets
361/// this on the child's env alongside the proxy vars; a cooperating child
362/// (currently `mur-research-gateway`, via `config::load`) reads it to source
363/// its own deny list. Single definition shared by both crates (CLAUDE.md
364/// rule 1: no duplicated literal).
365pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
366
367/// Per-MCP-server outbound egress policy.
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
369pub struct McpServerNetwork {
370    #[serde(default)]
371    pub mode: McpNetMode,
372    #[serde(default)]
373    pub allow_hosts: Vec<String>,
374    /// Deny overlay for `BroadAudited` mode: hosts blocked even though all
375    /// others are allowed. Ignored by `Restricted`/`Inherit`/`Off`.
376    #[serde(default)]
377    pub deny_hosts: Vec<String>,
378    /// Who authorized a `BroadAudited` grant, and when. `None` for other modes.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub authorization: Option<EgressAuthorization>,
381}
382
383/// A plugin-group imported by one agent (add-on Phase 2). Self-contained:
384/// members are installed PER-AGENT (skills under
385/// `~/.mur/agents/<a>/skills/`, mcp appended to this profile's
386/// `mcp_servers`). No global library, no refcounting.
387///
388/// Fail-closed: `enabled` defaults to `false`. Only an explicit user
389/// toggle (CLI/Hub) or a trusted native installer flips it true — the
390/// importer always constructs it `false`.
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
392pub struct AddonRef {
393    /// e.g. "superpowers" (local) or "superpowers@claude-plugins-official".
394    pub id: String,
395    /// Provenance, free-text. e.g. "claude-local:superpowers@6.0.3".
396    pub source: String,
397    #[serde(default)]
398    pub enabled: bool,
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub skills: Vec<String>,
401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
402    pub mcp: Vec<String>,
403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
404    pub commands: Vec<String>,
405}
406
407/// Display-only publisher metadata captured at install time. None of
408/// the fields are validated against any external authority — they're
409/// shown to the user during the install confirm prompt and reproduced
410/// in `mur agent mcp inspect` output so the user can audit who they
411/// thought they were trusting. (B0 rule 6 / M9.1)
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
413pub struct McpPublisherInfo {
414    /// Free-form publisher identifier — e.g. `"Anthropic"`,
415    /// `"@github-user-alice"`, or whatever `serverInfo.name` returned.
416    pub name: String,
417
418    /// Optional homepage / docs URL. Best-effort: extracted from the
419    /// MCP's `serverInfo.metadata.homepage` or registry entry when
420    /// available; otherwise left unset.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub homepage: Option<String>,
423
424    /// Optional registry coordinate — e.g. `"@anthropic-mcp/weather@1.2.3"`.
425    /// Used purely for display; not consumed by any verification path.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub registry_id: Option<String>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
431pub struct TransportConfig {
432    pub stdio: bool,
433    pub socket: SocketTransportConfig,
434    #[serde(default)]
435    pub tcp: TcpTransportConfig,
436    /// Track C5 — HTTP webhook receiver. Default off; enabling
437    /// requires an HMAC secret in the OS keychain (`SecretRef`).
438    /// See `docs/superpowers/specs/2026-05-05-mur-agent-c5-webhook-design.md`.
439    #[serde(default)]
440    pub webhook: WebhookTransportConfig,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
444pub struct TcpTransportConfig {
445    #[serde(default)]
446    pub enabled: bool,
447    #[serde(default)]
448    pub bind: String,
449    #[serde(default)]
450    pub noise: NoiseConfig,
451}
452
453/// HTTP webhook receiver — Track C5.
454///
455/// External systems POST `SharePayload`-shaped JSON to
456/// `http://<bind>:<port>/agents/<slug>/webhook` with an
457/// `X-Mur-Signature: sha256=<hex>` header carrying an HMAC-SHA256
458/// over the raw body. The HMAC secret is stored in the OS keychain
459/// via `SecretRef` (same pattern as Telegram bot tokens in C2);
460/// `hmac_secret_ref` is the `service:account` lookup key.
461///
462/// `bind` defaults to `127.0.0.1` so a fresh enable doesn't
463/// inadvertently expose the agent to the local network. Users who
464/// want VPN / Tailscale reachability override to `0.0.0.0` or the
465/// VPN interface address explicitly.
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
467pub struct WebhookTransportConfig {
468    #[serde(default)]
469    pub enabled: bool,
470    #[serde(default = "default_webhook_bind")]
471    pub bind: String,
472    #[serde(default = "default_webhook_port")]
473    pub port: u16,
474    /// `service:account` key into the OS keychain. Empty string
475    /// when `enabled = false`; required (and validated) at startup
476    /// when enabled.
477    #[serde(default)]
478    pub hmac_secret_ref: String,
479}
480
481fn default_webhook_bind() -> String {
482    "127.0.0.1".to_string()
483}
484
485fn default_webhook_port() -> u16 {
486    6789
487}
488
489impl Default for WebhookTransportConfig {
490    fn default() -> Self {
491        Self {
492            enabled: false,
493            bind: default_webhook_bind(),
494            port: default_webhook_port(),
495            hmac_secret_ref: String::new(),
496        }
497    }
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
501pub struct NoiseConfig {
502    pub pattern: String,
503}
504
505impl Default for NoiseConfig {
506    fn default() -> Self {
507        Self {
508            pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
509        }
510    }
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
514pub struct SocketTransportConfig {
515    pub enabled: bool,
516    pub bind: String, // "unix:///path" or "tcp://host:port" (P0b)
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub auth: Option<AuthConfig>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
522pub struct AuthConfig {
523    pub scheme: String,
524    pub token_file: String,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
528pub struct CommunicationConfig {
529    #[serde(default = "default_accepts_all")]
530    pub accepts_from: Vec<String>,
531    #[serde(default)]
532    pub sends_to: Vec<String>,
533}
534fn default_accepts_all() -> Vec<String> {
535    vec!["*".to_string()]
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
539pub struct Entitlements {
540    pub network: NetworkEntitlement,
541    pub filesystem: FilesystemEntitlement,
542    pub processes: ProcessesEntitlement,
543    #[serde(default)]
544    pub syscalls: SyscallsEntitlement,
545    #[serde(default)]
546    pub limits: LimitsEntitlement,
547    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
548    /// so the supervisor refuses to construct an LLM client.
549    #[serde(default)]
550    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
551    /// Per-tool allow/ask/deny policy. Empty = all tools use default (Ask).
552    #[serde(default, skip_serializing_if = "Vec::is_empty")]
553    pub tools: Vec<ToolRule>,
554    /// When `true` (the default), a sandbox apply failure is fatal: the agent
555    /// refuses to start rather than running advisory-only (unconfined).
556    /// Set to `false` only for development or trusted-workstation agents that
557    /// intentionally run without kernel sandbox enforcement.
558    #[serde(default = "default_true")]
559    pub fail_closed_on_sandbox_error: bool,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
563pub struct NetworkEntitlement {
564    pub inbound: InboundNetwork,
565    pub outbound: OutboundNetwork,
566}
567
568#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
569pub struct InboundNetwork {
570    #[serde(default)]
571    pub ports: Vec<u16>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
575pub struct OutboundNetwork {
576    pub mode: NetworkOutboundMode,
577    #[serde(default)]
578    pub allow_hosts: Vec<String>,
579    #[serde(default = "default_protocols")]
580    pub protocols: Vec<String>,
581    #[serde(default)]
582    pub resolve_dns: ResolveDnsConfig,
583}
584fn default_protocols() -> Vec<String> {
585    vec!["tcp".to_string()]
586}
587
588/// Record of who authorized a broad egress grant, and when. Attached to a
589/// per-MCP-server `McpServerNetwork` when its mode is `BroadAudited`, so the
590/// grant is persisted, portable, and re-approvable on import.
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
592pub struct EgressAuthorization {
593    pub authorized_by: String,
594    pub authorized_at_ms: u64,
595}
596
597#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
598#[serde(rename_all = "lowercase")]
599pub enum NetworkOutboundMode {
600    Unrestricted,
601    Restricted,
602    /// Deny all general outbound TCP; egress is ONLY via loopback proxies
603    /// (the agent's cc-proxy LLM port + the egress proxy). Hostnames are still
604    /// governed by `allow_hosts` (HostGuard) — unlike `Off`, which blocks all.
605    ProxyOnly,
606    Off,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
610pub struct ResolveDnsConfig {
611    #[serde(default = "default_dns_mode")]
612    pub mode: String,
613    #[serde(default)]
614    pub servers: Vec<String>,
615}
616impl Default for ResolveDnsConfig {
617    fn default() -> Self {
618        Self {
619            mode: default_dns_mode(),
620            servers: vec![],
621        }
622    }
623}
624fn default_dns_mode() -> String {
625    "system".to_string()
626}
627
628#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
629pub struct FilesystemEntitlement {
630    #[serde(default)]
631    pub read: Vec<String>,
632    #[serde(default)]
633    pub write: Vec<String>,
634    #[serde(default)]
635    pub deny: Vec<String>,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
639pub struct ProcessesEntitlement {
640    pub spawn: SpawnEntitlement,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
644pub struct SpawnEntitlement {
645    pub mode: SpawnMode,
646    #[serde(default)]
647    pub allowed: Vec<String>,
648}
649
650#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
651#[serde(rename_all = "lowercase")]
652pub enum SpawnMode {
653    Allowlist,
654    Any,
655    None,
656    /// Shell-only: fences the system exec paths (`/bin`, `/usr/bin`,
657    /// `/usr/lib`) that `Allowlist` mode exempts by default, so only the
658    /// resolved shell binary the `bash` tool itself spawns plus the
659    /// profile's own `spawn_allowed_paths`/`spawn_allowed_prefixes` may be
660    /// exec'd -- no other system binary (coreutils, `git`, etc.) is implied.
661    Strict,
662}
663
664#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
665pub struct SyscallsEntitlement {
666    #[serde(default = "default_syscalls_mode")]
667    pub mode: String,
668    #[serde(default)]
669    pub extra_deny: Vec<String>,
670}
671fn default_syscalls_mode() -> String {
672    "default".to_string()
673}
674
675#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
676pub struct LimitsEntitlement {
677    #[serde(default)]
678    pub cpu_seconds: Option<u64>,
679    #[serde(default = "default_memory_mb")]
680    pub memory_mb: u64,
681    #[serde(default = "default_fds")]
682    pub file_descriptors: u32,
683    #[serde(default = "default_procs")]
684    pub processes: u32,
685}
686fn default_memory_mb() -> u64 {
687    512
688}
689fn default_fds() -> u32 {
690    1024
691}
692fn default_procs() -> u32 {
693    32
694}
695
696#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
697#[serde(rename_all = "lowercase")]
698pub enum ToolPolicy {
699    Allow,
700    #[default]
701    Ask,
702    Deny,
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
706pub struct ToolRule {
707    pub pattern: String,
708    pub policy: ToolPolicy,
709    /// Intrinsic risk tier of this tool (v3c). Resolved most-restrictive-wins
710    /// against per-step risk + channel policy; gates pre-execution when not Read.
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub risk: Option<crate::hitl::RiskTier>,
713}
714
715/// Resolve the effective policy for `tool_name` against an ordered rule list.
716///
717/// Precedence: exact-name match > longest-prefix glob (trailing `*`) > default (`Ask`).
718pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
719    for rule in rules {
720        if rule.pattern == tool_name {
721            return rule.policy;
722        }
723    }
724    let mut best: Option<(&ToolRule, usize)> = None;
725    for rule in rules {
726        if let Some(prefix) = rule.pattern.strip_suffix('*')
727            && tool_name.starts_with(prefix)
728        {
729            let len = prefix.len();
730            if best.is_none_or(|(_, best_len)| len > best_len) {
731                best = Some((rule, len));
732            }
733        }
734    }
735    if let Some((rule, _)) = best {
736        return rule.policy;
737    }
738    ToolPolicy::default()
739}
740
741#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
742pub struct NotificationsConfig {
743    #[serde(default)]
744    pub on_task_complete: Vec<NotificationTarget>,
745    #[serde(default)]
746    pub on_error: Vec<NotificationTarget>,
747    #[serde(default)]
748    pub on_shutdown: Vec<NotificationTarget>,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
752#[serde(tag = "target", rename_all = "lowercase")]
753pub enum NotificationTarget {
754    Agent {
755        name: String,
756    },
757    Commander,
758    Email {
759        address: String,
760        #[serde(default)]
761        smtp_config_file: Option<String>,
762    },
763    Slack {
764        #[serde(default)]
765        channel: Option<String>,
766        #[serde(default)]
767        webhook_url_env: Option<String>,
768    },
769    Webpush {
770        url: String,
771    },
772    Webhook {
773        url: String,
774        #[serde(default = "default_post")]
775        method: String,
776        #[serde(default)]
777        auth: Option<String>,
778    },
779}
780fn default_post() -> String {
781    "POST".to_string()
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
785pub struct RetryConfig {
786    pub llm: RetryPolicy,
787    pub tool: RetryPolicy,
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
791pub struct RetryPolicy {
792    pub max_retries: u32,
793    pub backoff: BackoffStrategy,
794    pub initial_delay_ms: u64,
795    #[serde(default)]
796    pub max_delay_ms: Option<u64>,
797    #[serde(default)]
798    pub retry_on: Vec<String>,
799}
800
801#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
802#[serde(rename_all = "lowercase")]
803pub enum BackoffStrategy {
804    Linear,
805    Exponential,
806    Fixed,
807}
808
809#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
810pub struct LifecycleConfig {
811    pub restart: RestartPolicy,
812    #[serde(default = "default_max_restarts")]
813    pub max_restarts: u32,
814    #[serde(default = "default_window")]
815    pub restart_window_secs: u64,
816    #[serde(default = "default_stop_timeout")]
817    pub stop_timeout_secs: u64,
818    #[serde(default = "default_mcp_required")]
819    pub mcp_required: bool,
820    #[serde(default)]
821    pub execution: ExecutionMode,
822    #[serde(default)]
823    pub schedule: Vec<ScheduleEntry>,
824    #[serde(default)]
825    pub idle_triggers: Vec<IdleTrigger>,
826}
827fn default_max_restarts() -> u32 {
828    3
829}
830fn default_window() -> u64 {
831    600
832}
833fn default_stop_timeout() -> u64 {
834    15
835}
836fn default_mcp_required() -> bool {
837    true
838}
839
840#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
841#[serde(rename_all = "snake_case")]
842pub enum RestartPolicy {
843    Never,
844    OnFailure,
845    Always,
846}
847
848#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
849#[serde(rename_all = "snake_case")]
850pub enum ExecutionMode {
851    #[default]
852    Daemon,
853    OnDemand,
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
857pub struct ScheduleEntry {
858    pub cron: String,
859    pub message: String,
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub sends_to: Option<String>,
862}
863
864#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
865pub struct IdleTrigger {
866    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
867    pub after_secs: u64,
868    /// Message body injected into the task runner when this trigger fires.
869    pub message: String,
870    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
871    #[serde(default, skip_serializing_if = "Option::is_none")]
872    pub sends_to: Option<String>,
873    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
874    /// idle threshold is short and the runner finishes quickly. Default 600.
875    #[serde(default = "default_idle_cooldown")]
876    pub cooldown_secs: u64,
877    /// When true, suppress firing during the agent's quiet-hours window.
878    /// Default true — idle pings should not wake the user at 3 a.m.
879    #[serde(default = "default_true")]
880    pub respect_quiet_hours: bool,
881}
882
883fn default_idle_cooldown() -> u64 {
884    600
885}
886/// True if `name` is not present in a denylist (i.e. enabled).
887pub fn name_enabled(denylist: &[String], name: &str) -> bool {
888    !denylist.iter().any(|n| n == name)
889}
890
891/// Add/remove `name` in a denylist. `enabled=true` removes it (idempotent),
892/// `enabled=false` adds it once (idempotent).
893pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
894    if enabled {
895        list.retain(|n| n != name);
896    } else if !list.iter().any(|n| n == name) {
897        list.push(name.to_string());
898    }
899}
900
901fn default_true() -> bool {
902    true
903}
904
905#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
906pub struct FileTransferConfig {
907    #[serde(default = "default_accept_max")]
908    pub accept_incoming_file_max_bytes: u64,
909    #[serde(default = "default_accept_total")]
910    pub accept_incoming_total_per_hour: u64,
911    #[serde(default = "default_approval_threshold")]
912    pub require_approval_above_bytes: u64,
913    #[serde(default = "default_reject_paths")]
914    pub reject_paths: Vec<String>,
915    #[serde(default = "default_allowed_mime")]
916    pub allowed_mime_types: Vec<String>,
917}
918
919impl Default for FileTransferConfig {
920    fn default() -> Self {
921        Self {
922            accept_incoming_file_max_bytes: default_accept_max(),
923            accept_incoming_total_per_hour: default_accept_total(),
924            require_approval_above_bytes: default_approval_threshold(),
925            reject_paths: default_reject_paths(),
926            allowed_mime_types: default_allowed_mime(),
927        }
928    }
929}
930
931fn default_accept_max() -> u64 {
932    10_485_760
933}
934fn default_accept_total() -> u64 {
935    104_857_600
936}
937fn default_approval_threshold() -> u64 {
938    10_485_760
939}
940fn default_reject_paths() -> Vec<String> {
941    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
942}
943fn default_allowed_mime() -> Vec<String> {
944    vec!["*".into()]
945}
946
947#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
948#[serde(rename_all = "snake_case")]
949pub enum DeploymentType {
950    #[default]
951    Laptop,
952    Vm,
953    Docker,
954    K8s,
955    Lambda,
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
959pub struct DeploymentConfig {
960    #[serde(rename = "type", default)]
961    pub deployment_type: DeploymentType,
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    pub region: Option<String>,
964    #[serde(default = "default_env")]
965    pub environment: Option<String>,
966}
967
968impl Default for DeploymentConfig {
969    fn default() -> Self {
970        Self {
971            deployment_type: DeploymentType::default(),
972            region: None,
973            environment: default_env(),
974        }
975    }
976}
977
978fn default_env() -> Option<String> {
979    Some("dev".into())
980}
981
982#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
983pub struct LockFile {
984    pub schema: u32,
985    pub uuid: String,
986    pub name: String,
987    pub pid: u32,
988    pub ppid: u32,
989    pub started_at: String,
990    pub binary_version: String,
991    pub transports: LockTransports,
992    pub card_digest: String,
993    pub capabilities: Vec<String>,
994    /// Git sha the running binary was built from (mur_common::build::SHORT_SHA).
995    /// Empty = an old lock predating this field. Drives stale detection.
996    #[serde(default)]
997    pub build_sha: String,
998    /// A2A method-surface version this runtime supports (A2A_PROTO_VERSION).
999    /// 0 = an old lock; the dial gates versioned methods on it.
1000    #[serde(default)]
1001    pub proto_version: u32,
1002}
1003
1004#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1005pub struct LockTransports {
1006    pub stdio: bool,
1007    #[serde(default)]
1008    pub unix_socket: Option<String>,
1009    #[serde(default)]
1010    pub tcp: Option<String>,
1011    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
1012    /// Populated by the supervisor when `transport.webhook.enabled =
1013    /// true` so peers and the commander can discover the live
1014    /// endpoint without re-reading `profile.yaml`.
1015    #[serde(default)]
1016    pub webhook: Option<String>,
1017}
1018
1019// ──────────────────────────────────────────────────────────────────────────
1020// Voice I/O configuration (D1 — Kokoro 82M TTS + whisper.cpp STT)
1021// ──────────────────────────────────────────────────────────────────────────
1022
1023/// Kokoro 82M voice identity. Maps to the per-voice style vector
1024/// embedded in the Kokoro ONNX model.
1025#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1026#[serde(rename_all = "snake_case")]
1027pub enum VoiceId {
1028    /// Default: Kokoro af_heart voice.
1029    #[default]
1030    AfHeart,
1031    AfBella,
1032    AfNicole,
1033    AmAdam,
1034    AmMichael,
1035}
1036
1037impl VoiceId {
1038    /// Index into the Kokoro voices.bin style matrix (row index).
1039    pub fn style_index(&self) -> usize {
1040        match self {
1041            VoiceId::AfHeart => 0,
1042            VoiceId::AfBella => 1,
1043            VoiceId::AfNicole => 2,
1044            VoiceId::AmAdam => 3,
1045            VoiceId::AmMichael => 4,
1046        }
1047    }
1048
1049    /// Canonical lowercase string representation (matches `FromStr` inputs).
1050    pub fn as_str(&self) -> &'static str {
1051        match self {
1052            VoiceId::AfHeart => "af_heart",
1053            VoiceId::AfBella => "af_bella",
1054            VoiceId::AfNicole => "af_nicole",
1055            VoiceId::AmAdam => "am_adam",
1056            VoiceId::AmMichael => "am_michael",
1057        }
1058    }
1059}
1060
1061impl std::str::FromStr for VoiceId {
1062    type Err = anyhow::Error;
1063
1064    fn from_str(s: &str) -> anyhow::Result<Self> {
1065        match s {
1066            "af_heart" => Ok(VoiceId::AfHeart),
1067            "af_bella" => Ok(VoiceId::AfBella),
1068            "af_nicole" => Ok(VoiceId::AfNicole),
1069            "am_adam" => Ok(VoiceId::AmAdam),
1070            "am_michael" => Ok(VoiceId::AmMichael),
1071            other => anyhow::bail!(
1072                "unknown voice ID '{other}' \
1073                 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1074            ),
1075        }
1076    }
1077}
1078
1079/// Per-agent voice I/O configuration (D1).
1080/// Default = disabled so existing profiles continue to load unchanged.
1081#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1082pub struct VoiceConfig {
1083    /// Whether TTS (Kokoro) + STT (whisper.cpp) are enabled.
1084    #[serde(default)]
1085    pub enabled: bool,
1086    /// Kokoro voice identity for TTS output. Default: af_heart.
1087    #[serde(default)]
1088    pub voice_id: VoiceId,
1089    /// Optional cpal input device name for mic capture.
1090    /// None means the OS default input device.
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub input_device: Option<String>,
1093}
1094
1095// ──────────────────────────────────────────────────────────────────────────
1096// Human-in-the-loop configuration (Phase 2)
1097// ──────────────────────────────────────────────────────────────────────────
1098
1099#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1100pub struct HitlConfig {
1101    #[serde(default = "default_hitl_timeout_secs")]
1102    pub timeout_secs: u32,
1103    /// Hard cap on agentic-loop iterations (one LLM turn + its tool calls).
1104    /// `None` falls back to the runner default (25). On exceeding the cap the
1105    /// loop exits gracefully with a summary, not a hard error.
1106    #[serde(default)]
1107    pub max_iterations: Option<u32>,
1108    /// Per-task ceiling on cumulative *input* tokens for the agentic loop. When
1109    /// crossed before a turn, the loop stops gracefully with a summary.
1110    /// `None` falls back to the runner default (750_000 ≈ a few dollars on
1111    /// Sonnet); set a lower value per profile to bound spend tightly.
1112    #[serde(default)]
1113    pub max_tokens: Option<u64>,
1114}
1115
1116fn default_hitl_timeout_secs() -> u32 {
1117    300
1118}
1119
1120impl Default for HitlConfig {
1121    fn default() -> Self {
1122        Self {
1123            timeout_secs: default_hitl_timeout_secs(),
1124            max_iterations: None,
1125            max_tokens: None,
1126        }
1127    }
1128}
1129
1130#[cfg(test)]
1131mod hitl_tests {
1132    use super::*;
1133
1134    #[test]
1135    fn hitl_config_default_max_iterations_is_none() {
1136        let cfg = HitlConfig::default();
1137        assert!(cfg.max_iterations.is_none());
1138    }
1139
1140    #[test]
1141    fn hitl_config_max_iterations_explicit() {
1142        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1143        assert_eq!(cfg.max_iterations, Some(5));
1144    }
1145
1146    #[test]
1147    fn hitl_config_default_max_tokens_is_none() {
1148        let cfg = HitlConfig::default();
1149        assert!(cfg.max_tokens.is_none());
1150    }
1151
1152    #[test]
1153    fn hitl_config_max_tokens_explicit() {
1154        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1155        assert_eq!(cfg.max_tokens, Some(250_000));
1156    }
1157}
1158
1159// ──────────────────────────────────────────────────────────────────────────
1160// Companion subsystem (Phase 1.1+) — see
1161// docs/superpowers/specs/2026-04-29-mur-companion-phase-1-1-design.md §3.1
1162// ──────────────────────────────────────────────────────────────────────────
1163
1164#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1165pub struct CompanionConfig {
1166    #[serde(default)]
1167    pub enabled: bool,
1168    #[serde(default = "default_locale")]
1169    pub locale: String,
1170    #[serde(default)]
1171    pub relationship: Relationship,
1172    #[serde(default)]
1173    pub voice_overrides: VoiceOverrides,
1174    #[serde(default)]
1175    pub onboarding: OnboardingState,
1176    #[serde(default)]
1177    pub rhythm: RhythmConfig,
1178    #[serde(default)]
1179    pub proactive: ProactiveConfig,
1180}
1181
1182/// Resolve a default BCP-47 locale from the `LANG` environment variable
1183/// (e.g. `zh_TW.UTF-8` → `zh-TW`). Falls back to `en-US`.
1184pub fn default_locale() -> String {
1185    std::env::var("LANG")
1186        .ok()
1187        .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1188        .unwrap_or_else(|| "en-US".into())
1189}
1190
1191#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1192pub struct VoiceOverrides {
1193    #[serde(default, skip_serializing_if = "Option::is_none")]
1194    pub name_for_user: Option<String>,
1195    #[serde(default, skip_serializing_if = "Option::is_none")]
1196    pub formality: Option<Formality>,
1197    #[serde(default, skip_serializing_if = "Option::is_none")]
1198    pub extra_instructions: Option<String>,
1199}
1200
1201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1202pub struct FirstMemory {
1203    pub text: String,
1204    pub established_at: chrono::DateTime<chrono::Utc>,
1205}
1206
1207#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1208pub struct OnboardingState {
1209    #[serde(default, skip_serializing_if = "Option::is_none")]
1210    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1211    #[serde(default)]
1212    pub version: u32,
1213    #[serde(default, skip_serializing_if = "Option::is_none")]
1214    pub agent_display_name: Option<String>,
1215    #[serde(default, skip_serializing_if = "Option::is_none")]
1216    pub first_memory: Option<FirstMemory>,
1217}
1218
1219/// Phase 1.2 reservation. 1.1 keeps `enabled = false` (rhythm collection is
1220/// out of 1.1 scope).
1221#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1222pub struct RhythmConfig {
1223    #[serde(default)]
1224    pub enabled: bool,
1225}
1226
1227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1228pub struct ProactiveConfig {
1229    #[serde(default)]
1230    pub enabled: bool,
1231    /// 1.1 reserves the field; 1.2 will write `now + 7d` at rhythm-enable.
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1234    #[serde(default, skip_serializing_if = "Option::is_none")]
1235    pub quiet_hours: Option<QuietHours>,
1236    #[serde(default, skip_serializing_if = "Option::is_none")]
1237    pub active_hours: Option<ActiveHours>,
1238    #[serde(default = "default_daily_cap")]
1239    pub daily_cap: u8,
1240    #[serde(default = "default_channels")]
1241    pub channels: Vec<String>,
1242    #[serde(default, skip_serializing_if = "Option::is_none")]
1243    pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1244}
1245
1246impl Default for ProactiveConfig {
1247    fn default() -> Self {
1248        Self {
1249            enabled: false,
1250            learning_until: None,
1251            quiet_hours: None,
1252            active_hours: None,
1253            daily_cap: default_daily_cap(),
1254            channels: default_channels(),
1255            paused_until: None,
1256        }
1257    }
1258}
1259
1260fn default_daily_cap() -> u8 {
1261    3
1262}
1263fn default_channels() -> Vec<String> {
1264    vec!["stdout".into()]
1265}
1266
1267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1268pub struct QuietHours {
1269    pub start: String,
1270    pub end: String,
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1274pub struct ActiveHours {
1275    pub start: String,
1276    pub end: String,
1277}
1278
1279// ──────────────────────────────────────────────────────────────────────────
1280// Hub companion appearance (M-h3)
1281// ──────────────────────────────────────────────────────────────────────────
1282
1283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1284pub struct AgentAppearance {
1285    /// ID of the active style preset (e.g. "chiikawa", "default-blob").
1286    #[serde(default = "default_style_preset")]
1287    pub style_preset: String,
1288    #[serde(default)]
1289    pub behavior_preset: BehaviorPreset,
1290    /// Required for the polaroid family; none for all others.
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub source_image_path: Option<std::path::PathBuf>,
1293    /// Local dir where rendered .webp expression frames are stored.
1294    #[serde(default = "default_expressions_dir")]
1295    pub expressions_dir: std::path::PathBuf,
1296    #[serde(default, skip_serializing_if = "Option::is_none")]
1297    pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1298    #[serde(default)]
1299    pub render_status: RenderStatus,
1300}
1301
1302fn default_style_preset() -> String {
1303    "default-blob".into()
1304}
1305
1306fn default_expressions_dir() -> std::path::PathBuf {
1307    std::path::PathBuf::from("expressions")
1308}
1309
1310impl Default for AgentAppearance {
1311    fn default() -> Self {
1312        Self {
1313            style_preset: default_style_preset(),
1314            behavior_preset: BehaviorPreset::Normal,
1315            source_image_path: None,
1316            expressions_dir: default_expressions_dir(),
1317            last_rendered_at: None,
1318            render_status: RenderStatus::Pending,
1319        }
1320    }
1321}
1322
1323#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1324#[serde(rename_all = "snake_case")]
1325pub enum BehaviorPreset {
1326    Quiet,
1327    #[default]
1328    Normal,
1329    Lively,
1330}
1331
1332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1333#[serde(tag = "status", rename_all = "snake_case")]
1334pub enum RenderStatus {
1335    #[default]
1336    Pending,
1337    Rendering {
1338        done: u8,
1339        total: u8,
1340    },
1341    Ready,
1342    Failed {
1343        reason: String,
1344    },
1345}
1346
1347// ──────────────────────────────────────────────────────────────────────────
1348// E6 — Agent Pattern Federation types
1349// ──────────────────────────────────────────────────────────────────────────
1350
1351/// When the agent pulls an updated pattern snapshot from the daemon.
1352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1353#[serde(rename_all = "kebab-case")]
1354pub enum SnapshotPolicy {
1355    #[default]
1356    PullOnStart,
1357    PullPeriodic,
1358    Manual,
1359}
1360
1361/// Filter criteria for the pattern snapshot written to the agent's patterns_cache.
1362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1363pub struct PatternFilter {
1364    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1365    pub applies_in: Vec<String>,
1366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1367    pub tier: Vec<String>,
1368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1369    pub maturity: Vec<String>,
1370    #[serde(default)]
1371    pub importance_min: f64,
1372    #[serde(default = "default_max_snapshot_count")]
1373    pub max_count: usize,
1374    #[serde(default)]
1375    pub snapshot_policy: SnapshotPolicy,
1376}
1377
1378fn default_max_snapshot_count() -> usize {
1379    200
1380}
1381
1382impl Default for PatternFilter {
1383    fn default() -> Self {
1384        Self {
1385            applies_in: vec![],
1386            tier: vec![],
1387            maturity: vec![],
1388            importance_min: 0.0,
1389            max_count: 200,
1390            snapshot_policy: SnapshotPolicy::default(),
1391        }
1392    }
1393}
1394
1395/// Points to the knowledge-layer commit this agent's patterns_cache was built from.
1396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1397pub struct SnapshotRef {
1398    pub knowledge_commit: String,
1399    pub taken_at: String,
1400    pub filter: PatternFilter,
1401}
1402
1403/// Federation configuration embedded in AgentProfile (E6).
1404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1405pub struct FederationConfig {
1406    #[serde(default)]
1407    pub filter: PatternFilter,
1408    #[serde(default, skip_serializing_if = "Option::is_none")]
1409    pub snapshot_ref: Option<SnapshotRef>,
1410    #[serde(default)]
1411    pub evidence_flush_interval_minutes: u32,
1412}
1413
1414impl AgentProfile {
1415    /// Minimal valid profile for tests — no voice, no MCP, no skills.
1416    ///
1417    /// Available in all compilation modes so integration tests in
1418    /// dependent crates can call it (unlike `#[cfg(test)]` items which
1419    /// are invisible to downstream test binaries).
1420    #[doc(hidden)]
1421    pub fn default_for_tests() -> Self {
1422        serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1423            .expect("minimal profile fixture")
1424    }
1425
1426    /// Load an agent's profile from `<mur_home>/agents/<name>/profile.yaml`.
1427    ///
1428    /// Canonical read-path counterpart to the atomic-write path used by
1429    /// `mur agent create`/`mur agent mcp add` (`write_atomic` in
1430    /// `mur-core::cmd::agent`) — callers that already have `mur_home` in
1431    /// hand (e.g. provisioning flows, tests) can load a profile without
1432    /// going through the `MUR_HOME`-env-var-based `resolve_mur_home`.
1433    pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1434        let path = mur_home.join("agents").join(name).join("profile.yaml");
1435        let yaml = std::fs::read_to_string(&path)
1436            .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1437        serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1438    }
1439
1440    /// The imported add-on group a skill/mcp/command name belongs to.
1441    pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1442        self.addons.iter().find(|g| {
1443            g.skills.iter().any(|n| n == name)
1444                || g.mcp.iter().any(|n| n == name)
1445                || g.commands.iter().any(|n| n == name)
1446        })
1447    }
1448
1449    /// Whether `skill_name` is enabled (§3.3): not denied AND, if it
1450    /// belongs to an imported group, that group is enabled.
1451    pub fn skill_enabled(&self, skill_name: &str) -> bool {
1452        name_enabled(&self.disabled_skills, skill_name)
1453            && self.group_of(skill_name).is_none_or(|g| g.enabled)
1454    }
1455
1456    /// Whether MCP server `server_id` is enabled (§3.3).
1457    pub fn mcp_enabled(&self, server_id: &str) -> bool {
1458        name_enabled(&self.disabled_mcp, server_id)
1459            && self.group_of(server_id).is_none_or(|g| g.enabled)
1460    }
1461
1462    /// Toggle a skill for this agent without uninstalling it.
1463    pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1464        set_denylist(&mut self.disabled_skills, skill_name, enabled);
1465    }
1466
1467    /// Toggle an MCP server for this agent without removing it.
1468    pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1469        set_denylist(&mut self.disabled_mcp, server_id, enabled);
1470    }
1471
1472    /// Toggle an imported plugin-group as a unit. Returns false if no
1473    /// add-on has that id.
1474    pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1475        match self.addons.iter_mut().find(|g| g.id == addon_id) {
1476            Some(g) => {
1477                g.enabled = enabled;
1478                true
1479            }
1480            None => false,
1481        }
1482    }
1483
1484    /// Emergency kill-switch (§7): clears every add-on group's `enabled` flag.
1485    /// Members are already forced off by the group AND-gate in `skill_enabled` /
1486    /// `mcp_enabled`, so no denylist push is needed — and avoiding it means
1487    /// `set_addon_enabled(id, true)` fully restores the group without leftover
1488    /// per-member denials.
1489    pub fn disable_all_addons(&mut self) {
1490        for g in &mut self.addons {
1491            g.enabled = false;
1492        }
1493    }
1494
1495    /// This agent's MCP servers minus any disabled for it.
1496    pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1497        self.mcp_servers
1498            .iter()
1499            .filter(|m| self.mcp_enabled(&m.name))
1500            .cloned()
1501            .collect()
1502    }
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508
1509    #[test]
1510    fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1511        let net = McpServerNetwork {
1512            mode: McpNetMode::BroadAudited,
1513            allow_hosts: vec![],
1514            deny_hosts: vec!["evil.example".into()],
1515            authorization: Some(EgressAuthorization {
1516                authorized_by: "david".into(),
1517                authorized_at_ms: 1_750_000_000_000,
1518            }),
1519        };
1520        let y = serde_yaml::to_string(&net).unwrap();
1521        assert!(y.contains("broad_audited"));
1522        let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1523        assert_eq!(back, net);
1524        // legacy per-server policy without the new fields still parses (serde default)
1525        let legacy: McpServerNetwork =
1526            serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1527        assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1528        assert!(legacy.authorization.is_none());
1529    }
1530
1531    #[test]
1532    fn mcp_entry_network_is_optional_and_round_trips() {
1533        // Absent in YAML → None (every existing profile keeps working).
1534        let bare = "name: x\ncommand: npx\n";
1535        let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1536        assert!(e.network.is_none());
1537
1538        // Present → parsed.
1539        let with = "name: browser\ncommand: npx\nnetwork:\n  mode: restricted\n  allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1540        let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1541        let net = e2.network.expect("network present");
1542        assert_eq!(net.mode, McpNetMode::Restricted);
1543        assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1544
1545        // Round-trip keeps None out of the serialized form.
1546        let out = serde_yaml_ng::to_string(&e).unwrap();
1547        assert!(!out.contains("network"));
1548    }
1549
1550    #[test]
1551    fn profile_round_trip_yaml() {
1552        let yaml = r#"
1553schema: 1
1554id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1555name: agent_a
1556display_name: "Price Hunter"
1557version: "0.1.0"
1558persona:
1559  category: research
1560  description: "Finds prices"
1561  traits: { tone: concise, risk: cautious, verbosity: low }
1562sys_prompt_file: "sys_prompt.md"
1563model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1564mcp_servers: []
1565skills: []
1566transport:
1567  stdio: true
1568  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1569communication: { accepts_from: ["*"], sends_to: [] }
1570capabilities: ["a2a.message.send", "a2a.tasks"]
1571entitlements:
1572  network:
1573    inbound: { ports: [] }
1574    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1575  filesystem: { read: [], write: [], deny: [] }
1576  processes: { spawn: { mode: allowlist, allowed: [] } }
1577  syscalls: { mode: default }
1578  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1579notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1580retry:
1581  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1582  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1583lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1584created_at: "2026-04-22T10:00:00+08:00"
1585updated_at: "2026-04-22T10:00:00+08:00"
1586"#;
1587        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1588        assert_eq!(profile.name, "agent_a");
1589        assert_eq!(profile.persona.category, PersonaCategory::Research);
1590        assert_eq!(
1591            profile.entitlements.network.outbound.mode,
1592            NetworkOutboundMode::Restricted
1593        );
1594        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1595        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1596        assert_eq!(profile.id, round_tripped.id);
1597    }
1598}
1599
1600#[cfg(test)]
1601mod model_ref_tests {
1602    use super::*;
1603
1604    #[test]
1605    fn legacy_profile_without_model_ref_still_parses() {
1606        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1607        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1608        assert!(
1609            p.model_ref.is_none(),
1610            "legacy profile must not have model_ref"
1611        );
1612    }
1613
1614    #[test]
1615    fn round_trip_with_model_ref_preserves_field() {
1616        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1617        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1618        p.model_ref = Some("anthropic_opus_4_7".into());
1619        let s = serde_yaml_ng::to_string(&p).unwrap();
1620        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1621        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1622        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1623    }
1624}
1625
1626/// GUI-facing reification of the companion's three-layer permission toggle.
1627///
1628/// On-disk schema doesn't change — this helper just maps between the
1629/// three independent booleans (`enabled`, `rhythm.enabled`,
1630/// `proactive.enabled`) and a single ordered tier. Use
1631/// [`ProactiveTier::from_config`] to read and [`ProactiveTier::apply`]
1632/// to write.
1633#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1634#[serde(rename_all = "snake_case")]
1635pub enum ProactiveTier {
1636    Off,
1637    WarmOnly,
1638    WarmAndBehavior,
1639    All,
1640}
1641
1642impl ProactiveTier {
1643    pub fn from_config(c: &CompanionConfig) -> Self {
1644        match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1645            (false, _, _) => Self::Off,
1646            (true, false, false) => Self::WarmOnly,
1647            (true, true, false) => Self::WarmAndBehavior,
1648            (true, _, true) => Self::All,
1649        }
1650    }
1651
1652    pub fn apply(&self, c: &mut CompanionConfig) {
1653        match self {
1654            Self::Off => {
1655                c.enabled = false;
1656                c.rhythm.enabled = false;
1657                c.proactive.enabled = false;
1658            }
1659            Self::WarmOnly => {
1660                c.enabled = true;
1661                c.rhythm.enabled = false;
1662                c.proactive.enabled = false;
1663            }
1664            Self::WarmAndBehavior => {
1665                c.enabled = true;
1666                c.rhythm.enabled = true;
1667                c.proactive.enabled = false;
1668            }
1669            Self::All => {
1670                c.enabled = true;
1671                c.rhythm.enabled = true;
1672                c.proactive.enabled = true;
1673            }
1674        }
1675    }
1676}
1677
1678#[cfg(test)]
1679mod mcp_pin_tests {
1680    use super::*;
1681
1682    /// Pre-M9 profiles must continue to deserialize with the new
1683    /// optional fields absent. Round-trip: serialize back out and
1684    /// confirm the optional fields don't leak into the YAML.
1685    #[test]
1686    fn pre_m9_entry_roundtrips_without_pin_fields() {
1687        let yaml = r#"
1688name: weather
1689command: /opt/mcp/weather
1690args: ["--port", "0"]
1691"#;
1692        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1693        assert_eq!(entry.name, "weather");
1694        assert_eq!(entry.binary_sha256, None);
1695        assert_eq!(entry.description_hash, None);
1696        assert_eq!(entry.publisher, None);
1697        assert_eq!(entry.installed_at, None);
1698
1699        // skip_serializing_if = "Option::is_none" must keep the YAML
1700        // free of empty pin fields when the entry is pre-M9.
1701        let out = serde_yaml_ng::to_string(&entry).unwrap();
1702        assert!(!out.contains("binary_sha256"), "got {out}");
1703        assert!(!out.contains("description_hash"), "got {out}");
1704        assert!(!out.contains("publisher"), "got {out}");
1705        assert!(!out.contains("installed_at"), "got {out}");
1706    }
1707
1708    /// Full M9 entry with all fields set round-trips losslessly.
1709    #[test]
1710    fn full_m9_entry_roundtrips_all_fields() {
1711        let yaml = r#"
1712name: weather
1713command: /opt/mcp/weather
1714args: []
1715binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1716description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1717publisher:
1718  name: "@anthropic-mcp/weather"
1719  homepage: "https://github.com/anthropic-mcp/weather"
1720  registry_id: "@anthropic-mcp/weather@1.2.3"
1721installed_at: "2026-05-06T08:00:00Z"
1722"#;
1723        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1724        assert!(
1725            entry
1726                .binary_sha256
1727                .as_deref()
1728                .unwrap()
1729                .starts_with("3f4abca8")
1730        );
1731        assert!(
1732            entry
1733                .description_hash
1734                .as_deref()
1735                .unwrap()
1736                .starts_with("9a01b2c3")
1737        );
1738        let pub_info = entry.publisher.clone().unwrap();
1739        assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1740        assert_eq!(
1741            pub_info.homepage.as_deref(),
1742            Some("https://github.com/anthropic-mcp/weather"),
1743        );
1744        assert_eq!(
1745            pub_info.registry_id.as_deref(),
1746            Some("@anthropic-mcp/weather@1.2.3"),
1747        );
1748        let installed = entry.installed_at.unwrap();
1749        assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1750    }
1751
1752    /// Partial — only the binary hash is set (e.g. probe failed but
1753    /// install proceeded). The supervisor still needs to be able to
1754    /// deserialize this without panicking.
1755    #[test]
1756    fn partial_pin_only_binary_sha_roundtrips() {
1757        let yaml = r#"
1758name: weather
1759command: /opt/mcp/weather
1760args: []
1761binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1762"#;
1763        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1764        assert_eq!(
1765            entry.binary_sha256.as_deref(),
1766            Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1767        );
1768        assert_eq!(entry.description_hash, None);
1769        assert_eq!(entry.publisher, None);
1770    }
1771
1772    /// Publisher with only the required `name` field — homepage and
1773    /// registry_id are optional.
1774    #[test]
1775    fn publisher_minimal_just_name() {
1776        let yaml = r#"
1777name: weather
1778command: /opt/mcp/weather
1779args: []
1780publisher:
1781  name: "alice"
1782"#;
1783        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1784        let p = entry.publisher.as_ref().unwrap();
1785        assert_eq!(p.name, "alice");
1786        assert_eq!(p.homepage, None);
1787        assert_eq!(p.registry_id, None);
1788
1789        // skip_serializing_if must omit the optional sub-fields too.
1790        let out = serde_yaml_ng::to_string(&entry).unwrap();
1791        assert!(!out.contains("homepage:"), "got {out}");
1792        assert!(!out.contains("registry_id:"), "got {out}");
1793    }
1794}
1795
1796#[cfg(test)]
1797mod voice_tests {
1798    use super::*;
1799    use std::str::FromStr;
1800
1801    #[test]
1802    fn voice_config_round_trips() {
1803        // Base: use the canonical minimal fixture and append a voice: block.
1804        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1805        let yaml = format!("{base}voice:\n  enabled: true\n  voice_id: af_bella\n");
1806
1807        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1808        assert!(profile.voice.enabled);
1809        assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1810
1811        // Legacy profiles (no voice: block) must still load.
1812        let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1813        assert!(!legacy.voice.enabled);
1814        assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1815    }
1816
1817    #[test]
1818    fn voice_id_from_str_roundtrips() {
1819        let cases = [
1820            ("af_heart", VoiceId::AfHeart),
1821            ("af_bella", VoiceId::AfBella),
1822            ("af_nicole", VoiceId::AfNicole),
1823            ("am_adam", VoiceId::AmAdam),
1824            ("am_michael", VoiceId::AmMichael),
1825        ];
1826        for (s, expected) in cases {
1827            assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1828            assert_eq!(expected.as_str(), s);
1829        }
1830    }
1831
1832    #[test]
1833    fn voice_id_from_str_rejects_unknown() {
1834        assert!(VoiceId::from_str("bogus").is_err());
1835    }
1836}
1837
1838#[cfg(test)]
1839mod idle_trigger_tests {
1840    use super::*;
1841
1842    #[test]
1843    fn idle_trigger_yaml_round_trip() {
1844        let yaml = r#"
1845restart: on_failure
1846idle_triggers:
1847  - after_secs: 3600
1848    message: "still there?"
1849    sends_to: other_agent
1850    cooldown_secs: 1800
1851    respect_quiet_hours: true
1852"#;
1853        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1854        assert_eq!(cfg.idle_triggers.len(), 1);
1855        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1856        assert_eq!(cfg.idle_triggers[0].message, "still there?");
1857        assert_eq!(
1858            cfg.idle_triggers[0].sends_to.as_deref(),
1859            Some("other_agent")
1860        );
1861        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1862        assert!(cfg.idle_triggers[0].respect_quiet_hours);
1863    }
1864
1865    #[test]
1866    fn idle_trigger_defaults_when_omitted() {
1867        let yaml = "restart: on_failure\n";
1868        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1869        assert!(cfg.idle_triggers.is_empty());
1870    }
1871}
1872
1873#[cfg(test)]
1874mod appearance_tests {
1875    use super::*;
1876
1877    #[test]
1878    fn appearance_default_style_preset_is_default_blob() {
1879        assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1880    }
1881
1882    #[test]
1883    fn appearance_default_behavior_is_normal() {
1884        assert_eq!(
1885            AgentAppearance::default().behavior_preset,
1886            BehaviorPreset::Normal
1887        );
1888    }
1889
1890    #[test]
1891    fn appearance_default_render_status_is_pending() {
1892        assert_eq!(
1893            AgentAppearance::default().render_status,
1894            RenderStatus::Pending
1895        );
1896    }
1897
1898    #[test]
1899    fn render_status_serde_round_trip() {
1900        let cases = [
1901            RenderStatus::Pending,
1902            RenderStatus::Rendering { done: 3, total: 12 },
1903            RenderStatus::Ready,
1904            RenderStatus::Failed {
1905                reason: "out of quota".into(),
1906            },
1907        ];
1908        for status in cases {
1909            let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1910            let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1911            assert_eq!(status, back);
1912        }
1913    }
1914
1915    #[test]
1916    fn agent_profile_with_appearance_round_trips() {
1917        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1918        let yaml = format!(
1919            "{base}appearance:\n  style_preset: chiikawa\n  render_status:\n    status: ready\n"
1920        );
1921        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1922        assert_eq!(profile.appearance.style_preset, "chiikawa");
1923        assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1924
1925        let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1926        let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1927        assert_eq!(profile.appearance, back.appearance);
1928    }
1929
1930    #[test]
1931    fn legacy_profile_without_appearance_uses_default() {
1932        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1933        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1934        assert_eq!(profile.appearance.style_preset, "default-blob");
1935        assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1936        assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1937    }
1938
1939    #[test]
1940    fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1941        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1942        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1943        assert!(p.file_actions.is_empty());
1944        assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1945        assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1946    }
1947}
1948
1949#[cfg(test)]
1950mod federation_tests {
1951    use super::*;
1952
1953    #[test]
1954    fn test_pattern_filter_default() {
1955        let f = PatternFilter::default();
1956        assert_eq!(f.max_count, 200);
1957        assert_eq!(f.importance_min, 0.0);
1958        assert!(f.tier.is_empty());
1959    }
1960
1961    #[test]
1962    fn test_federation_config_roundtrip() {
1963        let cfg = FederationConfig {
1964            filter: PatternFilter {
1965                tier: vec!["core".into()],
1966                max_count: 50,
1967                ..Default::default()
1968            },
1969            snapshot_ref: Some(SnapshotRef {
1970                knowledge_commit: "abc123def456".into(),
1971                taken_at: "2026-05-19T00:00:00Z".into(),
1972                filter: PatternFilter::default(),
1973            }),
1974            evidence_flush_interval_minutes: 15,
1975        };
1976        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1977        let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1978        assert_eq!(cfg, back);
1979    }
1980
1981    #[test]
1982    fn test_agent_profile_federation_defaults() {
1983        // AgentProfile without a federation block deserializes with FederationConfig::default().
1984        // Use the minimal YAML that passes validation — just the required fields.
1985        // (We check only that the field has its zero value, not full profile parse.)
1986        let cfg = FederationConfig::default();
1987        assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1988        assert!(cfg.snapshot_ref.is_none());
1989    }
1990}
1991
1992#[cfg(test)]
1993mod skill_card_tests {
1994    use super::*;
1995
1996    #[test]
1997    fn installed_skills_default_to_empty_when_absent() {
1998        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1999        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2000        assert!(p.installed_skills.is_empty());
2001    }
2002
2003    #[test]
2004    fn installed_skills_roundtrip_preserves_entries() {
2005        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2006        let yaml = format!(
2007            "{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"
2008        );
2009        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2010        assert_eq!(p.installed_skills.len(), 1);
2011        assert_eq!(p.installed_skills[0].name, "s1");
2012        assert_eq!(p.installed_skills[0].abstract_text, "does things");
2013        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2014
2015        let out = serde_yaml_ng::to_string(&p).unwrap();
2016        assert!(out.contains("abstract: does things"));
2017        assert!(out.contains("pattern: /find"));
2018
2019        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2020        assert_eq!(p.installed_skills, back.installed_skills);
2021    }
2022
2023    #[test]
2024    fn installed_skills_minimal_entry_serializes_compactly() {
2025        // A name-only entry must NOT emit empty string fields.
2026        let entry = SkillCardEntry {
2027            name: "minimal".into(),
2028            ..Default::default()
2029        };
2030        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2031        assert!(yaml.contains("name: minimal"));
2032        assert!(
2033            !yaml.contains("version:"),
2034            "empty version must be skipped: {yaml}"
2035        );
2036        assert!(
2037            !yaml.contains("publisher:"),
2038            "empty publisher must be skipped: {yaml}"
2039        );
2040        assert!(
2041            !yaml.contains("abstract:"),
2042            "empty abstract must be skipped: {yaml}"
2043        );
2044    }
2045}
2046
2047#[cfg(test)]
2048mod tool_policy_tests {
2049    use super::*;
2050
2051    fn rules() -> Vec<ToolRule> {
2052        vec![
2053            ToolRule {
2054                pattern: "mcp__github__merge_pr".into(),
2055                policy: ToolPolicy::Ask,
2056                risk: None,
2057            },
2058            ToolRule {
2059                pattern: "mcp__github__*".into(),
2060                policy: ToolPolicy::Allow,
2061                risk: None,
2062            },
2063            ToolRule {
2064                pattern: "mcp__*".into(),
2065                policy: ToolPolicy::Deny,
2066                risk: None,
2067            },
2068            ToolRule {
2069                pattern: "bash".into(),
2070                policy: ToolPolicy::Allow,
2071                risk: None,
2072            },
2073        ]
2074    }
2075
2076    #[test]
2077    fn exact_beats_glob() {
2078        assert_eq!(
2079            resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2080            ToolPolicy::Ask
2081        );
2082    }
2083
2084    #[test]
2085    fn longer_glob_wins() {
2086        assert_eq!(
2087            resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2088            ToolPolicy::Allow
2089        );
2090    }
2091
2092    #[test]
2093    fn shorter_glob_fallback() {
2094        assert_eq!(
2095            resolve_tool_policy(&rules(), "mcp__slack__send"),
2096            ToolPolicy::Deny
2097        );
2098    }
2099
2100    #[test]
2101    fn exact_bash() {
2102        assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2103    }
2104
2105    #[test]
2106    fn unknown_tool_defaults_ask() {
2107        assert_eq!(
2108            resolve_tool_policy(&rules(), "unknown_tool"),
2109            ToolPolicy::Ask
2110        );
2111    }
2112
2113    #[test]
2114    fn empty_rules_defaults_ask() {
2115        assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2116    }
2117
2118    fn minimal_entitlements_yaml() -> &'static str {
2119        "network:\n  inbound: {}\n  outbound:\n    mode: off\nfilesystem: {}\nprocesses:\n  spawn:\n    mode: none\n"
2120    }
2121
2122    #[test]
2123    fn entitlements_tools_defaults_empty() {
2124        let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2125        assert!(e.tools.is_empty());
2126    }
2127
2128    #[test]
2129    fn entitlements_tools_roundtrip() {
2130        let base = minimal_entitlements_yaml();
2131        let yaml = format!("{base}tools:\n  - pattern: \"mcp__github__*\"\n    policy: allow\n");
2132        let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2133        assert_eq!(e.tools.len(), 1);
2134        assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2135        let y = serde_yaml_ng::to_string(&e).unwrap();
2136        let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2137        assert_eq!(back.tools.len(), 1);
2138        assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2139    }
2140    #[test]
2141    fn denylist_membership_and_mutation() {
2142        let mut list: Vec<String> = vec![];
2143        assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2144
2145        set_denylist(&mut list, "a", false); // disable
2146        assert!(!name_enabled(&list, "a"));
2147        assert_eq!(list, ["a"]);
2148
2149        set_denylist(&mut list, "a", false); // idempotent disable
2150        assert_eq!(list, ["a"], "no duplicate entries");
2151
2152        set_denylist(&mut list, "a", true); // enable removes
2153        assert!(name_enabled(&list, "a"));
2154        assert!(list.is_empty());
2155
2156        set_denylist(&mut list, "b", true); // enabling an absent name is a no-op
2157        assert!(list.is_empty());
2158    }
2159
2160    #[test]
2161    fn addon_group_rule_truth_table() {
2162        let mut p = AgentProfile::default_for_tests();
2163        p.addons.push(AddonRef {
2164            id: "grp".into(),
2165            source: "claude-local:grp@1.0.0".into(),
2166            enabled: false,
2167            skills: vec!["g_skill".into()],
2168            mcp: vec!["g_mcp".into()],
2169            commands: vec!["g_cmd".into()],
2170        });
2171
2172        // 1. standalone item, no entry anywhere => enabled (back-compat)
2173        assert!(p.skill_enabled("standalone"));
2174        assert!(p.mcp_enabled("standalone_mcp"));
2175
2176        // 2. grouped item, group disabled => off (cannot enable one member of a disabled group)
2177        assert!(!p.skill_enabled("g_skill"));
2178        assert!(!p.mcp_enabled("g_mcp"));
2179
2180        // 3. grouped item, group enabled, name not denied => on
2181        assert!(p.set_addon_enabled("grp", true));
2182        assert!(p.skill_enabled("g_skill"));
2183        assert!(p.mcp_enabled("g_mcp"));
2184
2185        // 4. name in denylist overrides an enabled group => off (silence one member)
2186        p.set_skill_enabled("g_skill", false);
2187        assert!(!p.skill_enabled("g_skill"));
2188
2189        // set_addon_enabled on a missing id reports false
2190        assert!(!p.set_addon_enabled("nope", true));
2191
2192        // kill-switch: only flips group flags — no denylist push
2193        p.disable_all_addons();
2194        assert!(p.addons.iter().all(|g| !g.enabled));
2195        assert!(!p.skill_enabled("g_skill"));
2196        assert!(!p.skill_enabled("g_cmd"));
2197        assert!(!p.mcp_enabled("g_mcp")); // mcp kill-switch asserted
2198
2199        // re-enable restores members — kill-switch is NOT sticky
2200        // (g_skill was individually denied in step 4 above and stays off;
2201        //  g_cmd and g_mcp were never individually denied so they come back on)
2202        assert!(p.set_addon_enabled("grp", true));
2203        assert!(!p.skill_enabled("g_skill")); // still individually denied from step 4
2204        assert!(p.skill_enabled("g_cmd")); // restored: never individually denied
2205        assert!(p.mcp_enabled("g_mcp")); // restored: never individually denied
2206
2207        // clearing the individual deny fully restores g_skill too
2208        p.set_skill_enabled("g_skill", true);
2209        assert!(p.skill_enabled("g_skill"));
2210    }
2211}
2212
2213#[cfg(test)]
2214mod lockfile_compat_tests {
2215    use super::*;
2216
2217    #[test]
2218    fn lockfile_new_fields_default_for_old_locks() {
2219        // An old lock JSON without build_sha/proto_version must still parse,
2220        // defaulting to "" / 0 (= "predates this feature → stale/unsupported").
2221        let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2222          "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2223          "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2224        let lock: LockFile = serde_json::from_str(old).unwrap();
2225        assert_eq!(lock.build_sha, "");
2226        assert_eq!(lock.proto_version, 0);
2227    }
2228}
2229
2230#[cfg(test)]
2231mod remote_mcp_tests {
2232    use super::*;
2233
2234    #[test]
2235    fn mcp_entry_roundtrips_remote_bearer() {
2236        let e = McpServerEntry {
2237            name: "gh".into(),
2238            command: String::new(),
2239            url: Some("https://api.example.com/mcp".into()),
2240            auth: Some(McpAuth::Bearer {
2241                token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2242            }),
2243            ..Default::default()
2244        };
2245        let y = serde_yaml_ng::to_string(&e).unwrap();
2246        let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2247        assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2248        assert!(matches!(
2249            back.auth,
2250            Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2251        ));
2252        // A legacy stdio entry (no url/auth) still parses.
2253        let legacy: McpServerEntry =
2254            serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2255        assert!(legacy.url.is_none());
2256        assert!(legacy.auth.is_none());
2257    }
2258}
2259
2260#[cfg(test)]
2261mod requires_programs_tests {
2262    #[test]
2263    fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2264        let with = r#"
2265name: research-gateway
2266command: mur-research-gateway
2267requires_programs:
2268  - name: lightpanda
2269    detect: { file: "~/.mur/aura/lightpanda" }
2270    reason: "render tier"
2271    registry: lightpanda
2272"#;
2273        let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2274        assert_eq!(e.requires_programs.len(), 1);
2275        assert_eq!(e.requires_programs[0].name, "lightpanda");
2276
2277        // Absent block → empty (back-compat).
2278        let without = "name: x\ncommand: y\n";
2279        let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2280        assert!(e2.requires_programs.is_empty());
2281    }
2282}