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