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