Skip to main content

mur_common/
agent.rs

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