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