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