1use crate::HarnessId;
4use serde_yaml::Value;
5
6use crate::diagnostic::{RemovedField, RemovedValue, push_invalid, unrecognized_field};
7use crate::frontmatter::{Frontmatter, SkillsSpec};
8use crate::harness_overrides::{HarnessOverrides, parse_harness_overrides};
9use crate::invocability::parse_invocability_axis;
10use crate::model_policy::ModelPolicyRule;
11use crate::network::{NetworkHost, NetworkPolicy};
12use crate::permission_set::PermissionSet;
13use crate::tool_policy::{self, EffectiveToolPolicy};
14
15pub(crate) const REMOVED_AGENT_FIELDS: &[RemovedField] = &[
17 RemovedField::discarded("fanout", "use consumer `[settings.meridian.fanout]`"),
18 RemovedField::discarded("models", "use `model-policies`"),
19 RemovedField::discarded("mcp-tools", "put MCP capability references in `tools`"),
20 RemovedField::discarded("autocompact", "configure compaction on the model alias"),
21 RemovedField::discarded("autocompact_pct", "configure compaction on the model alias"),
22 RemovedField::replaced("disallowed-tools", "use `tools: {<capability>: deny}`"),
23 RemovedField::replaced("model_invocable", "use `model-invocable`"),
24 RemovedField::replaced("user_invocable", "use `user-invocable`"),
25 RemovedField::replaced("network_allow", "use `network-allow`"),
26 RemovedField::replaced("network_deny", "use `network-deny`"),
27 RemovedField::replaced(
28 "permission",
29 "move it under `harness-overrides.meridian-flow.permission`",
30 ),
31 RemovedField::replaced(
32 "resident-rearm-budget",
33 "move it under `harness-overrides.meridian-cli.resident-rearm-budget`",
34 ),
35 RemovedField::replaced(
36 "meridian-capabilities",
37 "move it under `harness-overrides.meridian-cli.capabilities`",
38 ),
39];
40
41pub(crate) const REMOVED_AGENT_VALUES: &[RemovedValue] = &[
43 RemovedValue::new("approval", "yolo", "never"),
44 RemovedValue::new("effort", "adaptive", "medium"),
45 RemovedValue::new("effort", "disabled", "none"),
46];
47
48#[derive(Debug, Clone, Copy)]
52pub enum LoweredField {
53 Identity,
54 Model,
55 Skills,
56 Tools,
57 Effort,
58 Mode,
59}
60
61impl LoweredField {
62 pub const ALL: [Self; 6] = [
63 Self::Identity,
64 Self::Model,
65 Self::Skills,
66 Self::Tools,
67 Self::Effort,
68 Self::Mode,
69 ];
70
71 pub const fn keys(self) -> &'static [&'static str] {
73 match self {
74 Self::Identity => &["name", "description"],
75 Self::Model => &["model"],
76 Self::Skills => &["skills"],
77 Self::Tools => &["tools", "disallowed-tools"],
78 Self::Effort => &["effort"],
79 Self::Mode => &["mode"],
80 }
81 }
82
83 pub fn reserves(key: &str) -> bool {
85 Self::ALL.iter().any(|field| field.keys().contains(&key))
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
91#[serde(rename_all = "kebab-case")]
92pub enum AgentMode {
93 Primary,
94 Subagent,
95}
96
97impl AgentMode {
98 pub fn as_str(&self) -> &str {
99 match self {
100 AgentMode::Primary => "primary",
101 AgentMode::Subagent => "subagent",
102 }
103 }
104}
105
106impl std::fmt::Display for AgentMode {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.write_str(self.as_str())
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum ApprovalMode {
116 Default,
117 Auto,
118 Confirm,
119 Never,
120}
121
122impl ApprovalMode {
123 pub const ALLOWED: &'static str = "default, auto, confirm, never";
125
126 pub fn parse(s: &str) -> Option<Self> {
127 match s {
128 "default" => Some(Self::Default),
129 "auto" => Some(Self::Auto),
130 "confirm" => Some(Self::Confirm),
131 "never" => Some(Self::Never),
132 _ => None,
133 }
134 }
135
136 pub fn as_str(&self) -> &str {
137 match self {
138 Self::Default => "default",
139 Self::Auto => "auto",
140 Self::Confirm => "confirm",
141 Self::Never => "never",
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
148#[serde(rename_all = "kebab-case")]
149pub enum SandboxMode {
150 Default,
151 ReadOnly,
152 WorkspaceWrite,
153 DangerFullAccess,
154}
155
156impl SandboxMode {
157 pub const ALLOWED: &'static str = "default, read-only, workspace-write, danger-full-access";
159
160 pub fn parse(s: &str) -> Option<Self> {
161 match s {
162 "default" => Some(Self::Default),
163 "read-only" => Some(Self::ReadOnly),
164 "workspace-write" => Some(Self::WorkspaceWrite),
165 "danger-full-access" => Some(Self::DangerFullAccess),
166 _ => None,
167 }
168 }
169
170 pub fn as_str(&self) -> &str {
171 match self {
172 Self::Default => "default",
173 Self::ReadOnly => "read-only",
174 Self::WorkspaceWrite => "workspace-write",
175 Self::DangerFullAccess => "danger-full-access",
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
182#[serde(rename_all = "kebab-case")]
183pub enum EffortLevel {
184 None,
185 Low,
186 Medium,
187 High,
188 #[serde(rename = "xhigh")]
189 XHigh,
190 Max,
191}
192
193impl EffortLevel {
194 pub const ALLOWED: &'static str = "none, low, medium, high, xhigh, max";
196
197 pub fn parse(s: &str) -> Option<Self> {
198 match s.to_ascii_lowercase().as_str() {
199 "none" => Some(Self::None),
200 "low" => Some(Self::Low),
201 "medium" => Some(Self::Medium),
202 "high" => Some(Self::High),
203 "xhigh" => Some(Self::XHigh),
204 "max" => Some(Self::Max),
205 _ => None,
206 }
207 }
208
209 pub fn as_str(&self) -> &str {
210 match self {
211 Self::None => "none",
212 Self::Low => "low",
213 Self::Medium => "medium",
214 Self::High => "high",
215 Self::XHigh => "xhigh",
216 Self::Max => "max",
217 }
218 }
219}
220
221impl<'de> serde::Deserialize<'de> for EffortLevel {
224 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
225 let text = String::deserialize(deserializer)?;
226 Self::parse(&text).ok_or_else(|| {
227 serde::de::Error::custom(format!(
228 "`effort` has invalid value `{text}`; allowed: {}",
229 Self::ALLOWED
230 ))
231 })
232 }
233}
234
235#[derive(Debug, Clone, serde::Serialize)]
243#[serde(rename_all = "kebab-case")]
244pub struct AgentProfile {
245 pub name: Option<String>,
247 pub description: Option<String>,
248
249 pub harness: Option<HarnessId>,
251
252 pub model: Option<String>,
254
255 pub mode: Option<AgentMode>,
257 pub model_invocable: bool,
258 pub user_invocable: bool,
260 #[serde(skip)]
262 pub had_model_invocable_field: bool,
263 #[serde(skip)]
265 pub had_user_invocable_field: bool,
266 pub approval: Option<ApprovalMode>,
267 pub sandbox: Option<SandboxMode>,
268 pub network: Option<NetworkPolicy>,
269 pub network_allow: PermissionSet<NetworkHost>,
270 pub network_deny: PermissionSet<NetworkHost>,
271 pub effort: Option<EffortLevel>,
272
273 pub skills: SkillsSpec,
275 pub subagents: Vec<String>,
276 pub tools: EffectiveToolPolicy,
279
280 pub harness_overrides: HarnessOverrides,
282 pub model_policies: Vec<ModelPolicyRule>,
283}
284
285impl AgentProfile {
286 pub fn effective_native_config(
288 &self,
289 harness: &HarnessId,
290 ) -> Option<serde_json::Map<String, serde_json::Value>> {
291 self.harness_overrides
292 .get(harness)
293 .filter(|map| !map.is_empty())
294 .map(|map| {
295 map.iter()
296 .map(|(key, value)| (key.clone(), value.to_json()))
297 .collect()
298 })
299 }
300}
301
302fn push_agent_invalid(
303 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
304 path: Vec<crate::diagnostic::PathSegment>,
305 value: impl Into<String>,
306 allowed: &'static str,
307) {
308 push_invalid("agent", diags, path, value, allowed);
309}
310
311fn push_unknown_harness(diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>, value: &str) {
312 use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
313 diags.push(SchemaDiagnostic::new(
314 "harness.unknown",
315 Severity::Error,
316 key_path("harness"),
317 format!("unknown harness `{value}`; known: claude, codex, opencode, cursor, pi"),
318 ));
319}
320
321fn push_model_policy_parse_error(
322 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
323 position: usize,
324 error: crate::model_policy::ModelPolicyRuleParseError,
325) {
326 use crate::diagnostic::{PathSegment, key_path};
327 let mut rule_path = key_path("model-policies");
328 rule_path.push(PathSegment::Index(position));
329 let child = |key: &str| {
330 let mut path = rule_path.clone();
331 path.push(PathSegment::Key(key.to_owned()));
332 path
333 };
334 let grandchild = |first: &str, second: &str| {
335 let mut path = child(first);
336 path.push(PathSegment::Key(second.to_owned()));
337 path
338 };
339 match error {
340 crate::model_policy::ModelPolicyRuleParseError::RuleMustBeMapping { found } => {
341 push_agent_invalid(diags, rule_path, found, "mapping with match and override");
342 }
343 crate::model_policy::ModelPolicyRuleParseError::MatchMissing => {
344 push_agent_invalid(
345 diags,
346 child("match"),
347 "<missing>",
348 "mapping with exactly one of model, alias, model-glob",
349 );
350 }
351 crate::model_policy::ModelPolicyRuleParseError::MatchMustBeMapping { found } => {
352 push_agent_invalid(
353 diags,
354 child("match"),
355 found,
356 "mapping with exactly one of model, alias, model-glob",
357 );
358 }
359 crate::model_policy::ModelPolicyRuleParseError::MatchMustContainExactlyOne { found } => {
360 push_agent_invalid(
361 diags,
362 child("match"),
363 found,
364 "exactly one of model, alias, model-glob",
365 );
366 }
367 crate::model_policy::ModelPolicyRuleParseError::MatchKeyMustBeString { found } => {
368 push_agent_invalid(diags, child("match"), found, "model, alias, model-glob");
369 }
370 crate::model_policy::ModelPolicyRuleParseError::UnknownMatchKey { key } => {
371 push_agent_invalid(diags, child("match"), key, "model, alias, model-glob");
372 }
373 crate::model_policy::ModelPolicyRuleParseError::MatchValueMustBeString { key, found } => {
374 push_agent_invalid(diags, grandchild("match", &key), found, "non-empty string");
375 }
376 crate::model_policy::ModelPolicyRuleParseError::MatchValueEmpty { key } => {
377 push_agent_invalid(
378 diags,
379 grandchild("match", &key),
380 "<empty>",
381 "non-empty string",
382 );
383 }
384 crate::model_policy::ModelPolicyRuleParseError::OverrideMustBeMapping { found } => {
385 push_agent_invalid(diags, child("override"), found, "mapping");
386 }
387 crate::model_policy::ModelPolicyRuleParseError::UnknownOverrideKey { key } => {
388 push_agent_invalid(
389 diags,
390 grandchild("override", &key),
391 key,
392 "effort, sandbox, approval, network",
393 );
394 }
395 crate::model_policy::ModelPolicyRuleParseError::NetworkHostsNotAllowed { key } => {
396 push_agent_invalid(
397 diags,
398 grandchild("override", &key),
399 key,
400 "network host sets on settings, profile, overlay, or CLI",
401 );
402 }
403 crate::model_policy::ModelPolicyRuleParseError::OverrideValueInvalid {
404 key,
405 found,
406 allowed,
407 } => {
408 push_agent_invalid(diags, grandchild("override", &key), found, allowed);
409 }
410 crate::model_policy::ModelPolicyRuleParseError::NoFallbackMustBeBoolean { found } => {
411 push_agent_invalid(diags, child("no-fallback"), found, "boolean");
412 }
413 crate::model_policy::ModelPolicyRuleParseError::WhenInvalid { found } => {
414 push_agent_invalid(diags, child("when"), found, "non-empty one-line string");
415 }
416 }
417}
418
419fn parse_model_policies(
420 val: &Value,
421 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
422) -> Vec<ModelPolicyRule> {
423 let Some(seq) = val.as_sequence() else {
424 push_agent_invalid(
425 diags,
426 crate::diagnostic::key_path("model-policies"),
427 crate::ordered::plain(val),
428 "sequence of rules",
429 );
430 return vec![];
431 };
432
433 let mut out = Vec::new();
434 for (index, entry) in seq.iter().enumerate() {
435 match crate::model_policy::parse_model_policy_rule_value(entry) {
436 Ok(rule) => out.push(rule),
437 Err(error) => push_model_policy_parse_error(diags, index, error),
438 }
439 }
440 out
441}
442
443fn parse_invocability_field(
444 field: &str,
445 raw: Option<&Value>,
446 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
447) -> (bool, bool) {
448 let (value, had_field, invalid) = parse_invocability_axis(raw);
449 if let Some(invalid) = invalid {
450 push_agent_invalid(
451 diags,
452 crate::diagnostic::key_path(field),
453 invalid,
454 "boolean",
455 );
456 }
457 (value, had_field)
458}
459
460fn read_string(
461 value: &Value,
462 field: &'static str,
463 non_empty: bool,
464 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
465) -> Option<String> {
466 let Some(value) = value.as_str() else {
467 diags.push(crate::diagnostic::SchemaDiagnostic::new(
468 "field.type",
469 crate::diagnostic::Severity::Error,
470 crate::diagnostic::key_path(field),
471 format!("`{field}` must be a string"),
472 ));
473 return None;
474 };
475 if non_empty {
476 let value = value.trim();
477 if value.is_empty() {
478 diags.push(crate::diagnostic::SchemaDiagnostic::new(
479 "field.value",
480 crate::diagnostic::Severity::Error,
481 crate::diagnostic::key_path(field),
482 format!("`{field}` must be a non-empty string"),
483 ));
484 return None;
485 }
486 return Some(value.to_owned());
487 }
488 Some(value.to_owned())
489}
490
491fn read_string_list(
492 value: Option<&Value>,
493 path: Vec<crate::diagnostic::PathSegment>,
494 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
495) -> Vec<String> {
496 use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity, display_path};
497 let Some(value) = value else {
498 return Vec::new();
499 };
500 let Some(values) = value.as_sequence() else {
501 let display = display_path(&path);
502 diags.push(SchemaDiagnostic::new(
503 "field.type",
504 Severity::Error,
505 path,
506 format!("`{display}` must be a list of non-empty strings"),
507 ));
508 return Vec::new();
509 };
510 let display = display_path(&path);
511 values
512 .iter()
513 .enumerate()
514 .filter_map(|(index, value)| {
515 if let Value::String(text) = value
516 && !text.trim().is_empty()
517 {
518 return Some(text.clone());
519 }
520 let mut entry_path = path.clone();
521 entry_path.push(PathSegment::Index(index));
522 diags.push(SchemaDiagnostic::new(
523 if value.is_string() {
524 "field.value"
525 } else {
526 "field.type"
527 },
528 Severity::Error,
529 entry_path,
530 format!("`{display}` entry {} must be a non-empty string", index + 1),
531 ));
532 None
533 })
534 .collect()
535}
536
537fn read_host_set(
540 field: &'static str,
541 value: &Value,
542 allow_replace: bool,
543 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
544) -> PermissionSet<NetworkHost> {
545 use crate::diagnostic::{PathSegment, key_path};
546 const SHAPE: &str = "list of exact hosts, IPs, *.example.com, or **.example.com; or {replace: [...]} for an allow set";
547 let mut path = key_path(field);
548 let (replace, entries) = match value {
549 Value::Sequence(entries) => (false, entries),
550 Value::Mapping(mapping)
551 if mapping.len() == 1
552 && mapping
553 .get("replace")
554 .is_some_and(|entries| entries.is_sequence()) =>
555 {
556 if !allow_replace {
557 push_agent_invalid(
558 diags,
559 path,
560 crate::ordered::plain(value),
561 "plain list; package profiles cannot replace a deny set",
562 );
563 return PermissionSet::default();
564 }
565 path.push(PathSegment::Key("replace".to_owned()));
566 let entries = mapping.get("replace").and_then(Value::as_sequence);
567 (true, entries.expect("checked to be a sequence"))
568 }
569 _ => {
570 push_agent_invalid(diags, path, crate::ordered::plain(value), SHAPE);
571 return PermissionSet::default();
572 }
573 };
574 let hosts = entries
575 .iter()
576 .enumerate()
577 .filter_map(|(index, entry)| {
578 let parsed = entry
579 .as_str()
580 .ok_or_else(|| crate::ordered::plain(entry))
581 .and_then(|text| text.parse::<NetworkHost>().map_err(|_| text.to_owned()));
582 parsed
583 .map_err(|found| {
584 let mut entry_path = path.clone();
585 entry_path.push(PathSegment::Index(index));
586 push_agent_invalid(diags, entry_path, found, NetworkHost::ALLOWED);
587 })
588 .ok()
589 })
590 .collect();
591 if replace {
592 PermissionSet::Replace { replace: hosts }
593 } else {
594 PermissionSet::Add(hosts)
595 }
596}
597
598fn read_skills(value: &Value, diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>) -> SkillsSpec {
599 use crate::diagnostic::{PathSegment, key_path};
600 let root = key_path("skills");
601 let Value::Mapping(mapping) = value else {
602 return SkillsSpec {
603 load: read_string_list(Some(value), root, diags),
604 available: Vec::new(),
605 };
606 };
607 let read_axis = |name: &str, diags: &mut Vec<_>| {
608 let mut path = root.clone();
609 path.push(PathSegment::Key(name.to_owned()));
610 read_string_list(mapping.get(Value::String(name.to_owned())), path, diags)
611 };
612 SkillsSpec {
613 load: read_axis("load", diags),
614 available: read_axis("available", diags),
615 }
616}
617
618pub(crate) fn read_fields(
619 fm: &Frontmatter,
620 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
621) -> AgentProfile {
622 use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
623
624 let mut profile = AgentProfile {
625 name: None,
626 description: None,
627 harness: None,
628 model: None,
629 mode: None,
630 model_invocable: true,
631 user_invocable: true,
632 had_model_invocable_field: false,
633 had_user_invocable_field: false,
634 approval: None,
635 sandbox: None,
636 network: None,
637 network_allow: PermissionSet::default(),
638 network_deny: PermissionSet::default(),
639 effort: None,
640 skills: SkillsSpec::default(),
641 subagents: Vec::new(),
642 tools: EffectiveToolPolicy::default(),
643 harness_overrides: HarnessOverrides::default(),
644 model_policies: Vec::new(),
645 };
646
647 for key in fm.keys() {
648 let value = fm.get(&key).expect("frontmatter key came from mapping");
649 match key.as_str() {
650 "name" => profile.name = read_string(value, "name", true, diags),
651 "description" => {
652 profile.description = read_string(value, "description", false, diags);
653 }
654 "harness" => {
655 profile.harness = read_string(value, "harness", false, diags).and_then(|name| {
656 crate::harness::parse(&name).or_else(|| {
657 push_unknown_harness(diags, &name);
658 None
659 })
660 });
661 }
662 "model" => profile.model = read_string(value, "model", true, diags),
663 "mode" => {
664 profile.mode =
665 read_string(value, "mode", false, diags).and_then(|mode| match mode.as_str() {
666 "primary" => Some(AgentMode::Primary),
667 "subagent" => Some(AgentMode::Subagent),
668 other => {
669 push_agent_invalid(
670 diags,
671 key_path("mode"),
672 other.to_string(),
673 "primary, subagent",
674 );
675 None
676 }
677 });
678 }
679 "model-invocable" => {
680 (profile.model_invocable, profile.had_model_invocable_field) =
681 parse_invocability_field("model-invocable", Some(value), diags);
682 }
683 "user-invocable" => {
684 (profile.user_invocable, profile.had_user_invocable_field) =
685 parse_invocability_field("user-invocable", Some(value), diags);
686 }
687 "approval" => {
688 profile.approval =
689 read_string(value, "approval", false, diags).and_then(|approval| {
690 if let Some(removed) =
691 RemovedValue::find(REMOVED_AGENT_VALUES, "approval", &approval)
692 {
693 diags.push(removed.diagnostic());
694 return None;
695 }
696 ApprovalMode::parse(&approval).or_else(|| {
697 push_agent_invalid(
698 diags,
699 key_path("approval"),
700 approval,
701 ApprovalMode::ALLOWED,
702 );
703 None
704 })
705 });
706 }
707 "permission" => diags.push(removed_permission(value)),
708 "sandbox" => {
709 profile.sandbox = read_string(value, "sandbox", false, diags).and_then(|sandbox| {
710 SandboxMode::parse(&sandbox).or_else(|| {
711 push_agent_invalid(
712 diags,
713 key_path("sandbox"),
714 sandbox,
715 SandboxMode::ALLOWED,
716 );
717 None
718 })
719 });
720 }
721 "network" => {
722 profile.network = read_string(value, "network", false, diags).and_then(|network| {
723 NetworkPolicy::parse(&network).or_else(|| {
724 push_agent_invalid(
725 diags,
726 key_path("network"),
727 network,
728 NetworkPolicy::ALLOWED,
729 );
730 None
731 })
732 });
733 }
734 "network-allow" => {
735 profile.network_allow = read_host_set("network-allow", value, true, diags);
736 }
737 "network-deny" => {
738 profile.network_deny = read_host_set("network-deny", value, false, diags);
739 }
740 "effort" => {
741 profile.effort = read_string(value, "effort", false, diags).and_then(|effort| {
742 if let Some(removed) =
743 RemovedValue::find(REMOVED_AGENT_VALUES, "effort", &effort)
744 {
745 diags.push(removed.diagnostic());
746 return None;
747 }
748 EffortLevel::parse(&effort).or_else(|| {
749 push_agent_invalid(diags, key_path("effort"), effort, EffortLevel::ALLOWED);
750 None
751 })
752 });
753 }
754 "skills" => profile.skills = read_skills(value, diags),
755 "subagents" => {
756 profile.subagents = read_string_list(Some(value), key_path("subagents"), diags);
757 }
758 "tools" => {
759 if value.is_sequence() {
760 diags.push(SchemaDiagnostic::new(
761 "tools.list-form",
762 Severity::Error,
763 key_path("tools"),
764 "`tools` must be an action map; list form was removed",
765 ));
766 } else {
767 let mut invalid = |path, value: &str, allowed: &'static str| {
768 push_agent_invalid(diags, path, value, allowed);
769 };
770 profile.tools = tool_policy::parse_tool_policy(Some(value), &mut invalid);
771 }
772 }
773 "harness-overrides" => {
774 profile.harness_overrides = parse_harness_overrides("agent", value, diags);
775 }
776 "model-policies" => profile.model_policies = parse_model_policies(value, diags),
777 _ => diags.push(unrecognized_field(REMOVED_AGENT_FIELDS, &key)),
778 }
779 }
780
781 profile
782}
783
784fn removed_permission(value: &Value) -> crate::diagnostic::SchemaDiagnostic {
789 use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
790 match value {
791 Value::Mapping(_) => SchemaDiagnostic::new(
792 "field.removed",
793 Severity::Error,
794 key_path("permission"),
795 "`permission` as a map is OpenCode's format; put it under \
796 `harness-overrides.opencode.permission`",
797 ),
798 Value::String(permission) if matches!(permission.as_str(), "read" | "edit") => {
799 unrecognized_field(REMOVED_AGENT_FIELDS, "permission")
800 }
801 _ => SchemaDiagnostic::new(
802 "field.removed",
803 Severity::Error,
804 key_path("permission"),
805 "`permission` was removed; Meridian Flow reads only `read` or `edit`, at \
806 `harness-overrides.meridian-flow.permission`. Grant what it allowed with \
807 `tools: {<capability>: allow}`",
808 ),
809 }
810}
811
812pub fn read(content: &str) -> crate::read::DefinitionRead<AgentProfile> {
814 crate::read::read_with(content, read_fields)
815}