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