1use cookie_agent_identity::AgentId;
2use cookie_agent_models::Sha256Digest;
3pub use cookie_agent_protocol::AgentDocumentSource;
4use serde::Serialize;
5use sha2::{Digest as _, Sha256};
6use std::path::Path;
7
8use crate::{AgentFrontmatter, ConfigError};
9
10const MAX_FRONTMATTER_BYTES: usize = 128 * 1024;
11const MAX_BODY_BYTES: usize = 128 * 1024;
12const MAX_LIST: usize = 256;
13
14#[derive(Clone, Debug)]
15pub struct AgentDocument {
16 pub id: AgentId,
17 pub frontmatter: AgentFrontmatter,
18 pub body: String,
19 pub source: AgentDocumentSource,
20 pub document_fingerprint: Sha256Digest,
21 pub prompt_fingerprint: Sha256Digest,
22}
23
24pub(crate) fn parse_agent(
25 id: AgentId,
26 bytes: &[u8],
27 source: AgentDocumentSource,
28 path: &Path,
29) -> Result<AgentDocument, ConfigError> {
30 let text = std::str::from_utf8(bytes)
31 .map_err(|_| invalid_document(path, "content is not UTF-8"))?
32 .replace("\r\n", "\n")
33 .replace('\r', "\n");
34 if text.contains("${env:") {
35 return Err(invalid_document(
36 path,
37 "environment interpolation is not allowed",
38 ));
39 }
40 let rest = text
41 .strip_prefix("---\n")
42 .ok_or_else(|| invalid_document(path, "frontmatter must start with `---`"))?;
43 let closing = rest
44 .find("\n---\n")
45 .ok_or_else(|| invalid_document(path, "frontmatter must end with `---`"))?;
46 let yaml = &rest[..closing];
47 let raw_body = &rest[closing + "\n---\n".len()..];
48 if yaml.len() > MAX_FRONTMATTER_BYTES || raw_body.len() > MAX_BODY_BYTES || forbidden_yaml(yaml)
49 {
50 return Err(invalid_document(
51 path,
52 "frontmatter is too large or uses unsupported YAML features",
53 ));
54 }
55 let yaml_value: serde_yaml::Value =
56 serde_yaml::from_str(yaml).map_err(|error| yaml_error(path, &error))?;
57 validate_yaml_limits(&yaml_value, 0)
58 .map_err(|_| invalid_document(path, "YAML resource limit exceeded"))?;
59 reject_schema_field(&yaml_value, yaml, path)?;
60 reject_renamed_models_field(&yaml_value, &id)?;
61 reject_removed_tools_field(&yaml_value, &id)?;
62 validate_permission_expressions(&yaml_value, &id)?;
63 let frontmatter: AgentFrontmatter =
64 serde_yaml::from_str(yaml).map_err(|error| yaml_error(path, &error))?;
65 let body = format!("{}\n", raw_body.trim_end_matches('\n'));
66 if !body.chars().any(|character| !character.is_whitespace()) {
67 return Err(ConfigError::EmptyPrompt(id));
68 }
69 let document_fingerprint = hash(
70 "cookie-agent/agent-document/v1",
71 &(id.as_str(), yaml, &body),
72 );
73 let prompt_fingerprint = hash("cookie-agent/system-prompt/v1", &body);
74 Ok(AgentDocument {
75 id,
76 frontmatter,
77 body,
78 source,
79 document_fingerprint,
80 prompt_fingerprint,
81 })
82}
83
84fn reject_schema_field(
85 value: &serde_yaml::Value,
86 yaml: &str,
87 path: &Path,
88) -> Result<(), ConfigError> {
89 let schema = serde_yaml::Value::String("schema".to_owned());
90 if value
91 .as_mapping()
92 .is_some_and(|frontmatter| frontmatter.contains_key(&schema))
93 {
94 let line = yaml
95 .lines()
96 .position(|line| line.trim_start().starts_with("schema:"))
97 .map_or(2, |index| index + 2);
98 return Err(ConfigError::AgentSchemaRemoved {
99 path: path.to_owned(),
100 line,
101 });
102 }
103 Ok(())
104}
105
106fn invalid_document(path: &Path, message: impl Into<String>) -> ConfigError {
107 ConfigError::AgentDocument {
108 path: path.to_owned(),
109 message: message.into(),
110 }
111}
112
113fn yaml_error(path: &Path, error: &serde_yaml::Error) -> ConfigError {
114 let location = error
115 .location()
116 .map(|location| {
117 format!(
118 " at line {}, column {}",
119 location.line() + 1,
120 location.column()
121 )
122 })
123 .unwrap_or_default();
124 let rendered = error.to_string();
125 let message = extract_serde_field(&rendered, "unknown field")
126 .map(|field| format!("unknown field `{field}`{location}; remove it"))
127 .or_else(|| {
128 extract_serde_field(&rendered, "missing field")
129 .map(|field| format!("missing required field `{field}`{location}"))
130 })
131 .unwrap_or_else(|| format!("malformed YAML frontmatter{location}"));
132 invalid_document(path, message)
133}
134
135fn extract_serde_field(message: &str, marker: &str) -> Option<String> {
136 let rest = message.split_once(marker)?.1.trim_start();
137 let quote = rest.chars().next()?;
138 if !matches!(quote, '`' | '\'' | '"') {
139 return None;
140 }
141 rest[quote.len_utf8()..]
142 .split_once(quote)
143 .map(|(field, _)| field.to_owned())
144}
145
146fn reject_removed_tools_field(
147 value: &serde_yaml::Value,
148 agent: &AgentId,
149) -> Result<(), ConfigError> {
150 let tools = serde_yaml::Value::String("tools".to_owned());
151 if value
152 .as_mapping()
153 .is_some_and(|frontmatter| frontmatter.contains_key(&tools))
154 {
155 return Err(ConfigError::AgentToolsRemoved(agent.clone()));
156 }
157 Ok(())
158}
159
160fn reject_renamed_models_field(
161 value: &serde_yaml::Value,
162 agent: &AgentId,
163) -> Result<(), ConfigError> {
164 let legacy = serde_yaml::Value::String("model_fallback".to_owned());
165 if value
166 .as_mapping()
167 .is_some_and(|frontmatter| frontmatter.contains_key(&legacy))
168 {
169 return Err(ConfigError::AgentModelsRenamed(agent.clone()));
170 }
171 Ok(())
172}
173
174fn validate_permission_expressions(
175 value: &serde_yaml::Value,
176 agent: &AgentId,
177) -> Result<(), ConfigError> {
178 let Some(frontmatter) = value.as_mapping() else {
179 return Ok(());
180 };
181 let permissions_key = serde_yaml::Value::String("permissions".to_owned());
182 let Some(permissions) = frontmatter
183 .get(&permissions_key)
184 .and_then(serde_yaml::Value::as_mapping)
185 else {
186 return Ok(());
187 };
188 for (action, permission) in permissions {
189 let Some(action) = action.as_str() else {
190 continue;
191 };
192 let Some(resources) = permission.as_mapping() else {
193 continue;
194 };
195 for resource in resources.keys().filter_map(serde_yaml::Value::as_str) {
196 let remainder = resource.replace("${workspace_dir}", "");
197 let malformed_expression = remainder.contains("${");
198 let unsupported_action =
199 resource.contains("${workspace_dir}") && !matches!(action, "read" | "write");
200 if malformed_expression || unsupported_action {
201 return Err(ConfigError::AgentPermissionExpression(agent.clone()));
202 }
203 }
204 }
205 Ok(())
206}
207
208fn forbidden_yaml(yaml: &str) -> bool {
209 yaml.lines().any(|line| {
210 if line.trim_start().starts_with("<<:") {
211 return true;
212 }
213 let mut single = false;
214 let mut double = false;
215 let mut escaped = false;
216 for (index, character) in line.char_indices() {
217 if escaped {
218 escaped = false;
219 continue;
220 }
221 if double && character == '\\' {
222 escaped = true;
223 continue;
224 }
225 match character {
226 '\'' if !double => single = !single,
227 '"' if !single => double = !double,
228 '#' if !single && !double => break,
229 '&' | '!' | '*' if !single && !double => {
230 let boundary = index == 0
231 || line[..index]
232 .chars()
233 .next_back()
234 .is_some_and(char::is_whitespace);
235 if boundary {
236 return true;
237 }
238 }
239 _ => {}
240 }
241 }
242 false
243 })
244}
245
246fn validate_yaml_limits(value: &serde_yaml::Value, depth: usize) -> Result<(), ConfigError> {
247 if depth > 16 {
248 return Err(ConfigError::AgentYamlLimit);
249 }
250 match value {
251 serde_yaml::Value::Sequence(values) => {
252 if values.len() > MAX_LIST {
253 return Err(ConfigError::AgentYamlLimit);
254 }
255 for value in values {
256 validate_yaml_limits(value, depth + 1)?;
257 }
258 }
259 serde_yaml::Value::Mapping(values) => {
260 if values.len() > MAX_LIST {
261 return Err(ConfigError::AgentYamlLimit);
262 }
263 for (key, value) in values {
264 validate_yaml_limits(key, depth + 1)?;
265 validate_yaml_limits(value, depth + 1)?;
266 }
267 }
268 serde_yaml::Value::Tagged(_) => return Err(ConfigError::AgentYamlLimit),
269 serde_yaml::Value::String(value) if value.len() > 128 * 1024 => {
270 return Err(ConfigError::AgentYamlLimit);
271 }
272 _ => {}
273 }
274 Ok(())
275}
276
277fn hash(domain: &str, value: &impl Serialize) -> Sha256Digest {
278 let mut hasher = Sha256::new();
279 hasher.update(domain.as_bytes());
280 hasher.update([0]);
281 hasher.update(serde_json::to_vec(value).expect("strings always serialize to JSON"));
282 Sha256Digest::new(format!("{:x}", hasher.finalize()))
283 .expect("SHA-256 output is always a valid digest")
284}