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