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