1use std::path::Path;
19
20use crate::error::PawError;
21
22pub const DEV_ALLOWLIST_PRESET: &[&str] = &[
36 "cargo build",
38 "cargo test",
39 "cargo clippy",
40 "cargo fmt",
41 "cargo check",
42 "cargo tree",
43 "cargo deny",
44 "cargo update",
45 "git status",
47 "git log",
48 "git diff",
49 "git show",
50 "git fetch",
51 "git commit",
53 "git push",
54 "git pull",
55 "git merge",
56 "git stash",
57 "git add",
58 "git restore",
59 "git rm",
60 "just",
62 "mdbook build",
64 "openspec validate",
66 "openspec new",
67 "openspec archive",
68 "openspec list",
69 "openspec status",
70 "openspec instructions",
71 "find",
73 "grep",
74 "sed -n",
75];
76
77#[must_use]
85pub fn effective_patterns(extra: &[String]) -> Vec<String> {
86 let mut out: Vec<String> = DEV_ALLOWLIST_PRESET
87 .iter()
88 .map(|s| (*s).to_string())
89 .collect();
90 for entry in extra {
91 if !out.iter().any(|existing| existing == entry) {
92 out.push(entry.clone());
93 }
94 }
95 out
96}
97
98pub fn setup_dev_allowlist(extra: &[String], settings_path: &Path) -> Result<(), PawError> {
121 let new_entries = effective_patterns(extra);
122
123 let mut value: serde_json::Value = if settings_path.exists() {
124 let raw = std::fs::read_to_string(settings_path).map_err(|e| {
125 PawError::ConfigError(format!("failed to read {}: {e}", settings_path.display()))
126 })?;
127 if raw.trim().is_empty() {
128 serde_json::Value::Object(serde_json::Map::new())
129 } else {
130 serde_json::from_str(&raw).map_err(|e| {
131 PawError::ConfigError(format!("{}: invalid JSON: {e}", settings_path.display()))
132 })?
133 }
134 } else {
135 serde_json::Value::Object(serde_json::Map::new())
136 };
137
138 let obj = value.as_object_mut().ok_or_else(|| {
139 PawError::ConfigError(format!(
140 "{}: top-level value must be a JSON object",
141 settings_path.display()
142 ))
143 })?;
144
145 let entry = obj
146 .entry("allowed_bash_prefixes".to_string())
147 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
148
149 let array = entry.as_array_mut().ok_or_else(|| {
150 PawError::ConfigError(format!(
151 "{}: allowed_bash_prefixes must be an array",
152 settings_path.display()
153 ))
154 })?;
155
156 for new_entry in new_entries {
157 let already_present = array
158 .iter()
159 .any(|v| v.as_str().is_some_and(|s| s == new_entry));
160 if !already_present {
161 array.push(serde_json::Value::String(new_entry));
162 }
163 }
164
165 if let Some(parent) = settings_path.parent()
166 && !parent.as_os_str().is_empty()
167 {
168 std::fs::create_dir_all(parent).map_err(|e| {
169 PawError::ConfigError(format!("failed to create {}: {e}", parent.display()))
170 })?;
171 }
172
173 let serialized = serde_json::to_string_pretty(&value).map_err(|e| {
174 PawError::ConfigError(format!(
175 "failed to serialize {}: {e}",
176 settings_path.display()
177 ))
178 })?;
179 std::fs::write(settings_path, serialized).map_err(|e| {
180 PawError::ConfigError(format!("failed to write {}: {e}", settings_path.display()))
181 })?;
182 Ok(())
183}
184
185pub fn seed_supervisor_session(
205 extra: &[String],
206 repo_root: &Path,
207 alt_settings: &[std::path::PathBuf],
208) -> Vec<(std::path::PathBuf, PawError)> {
209 let mut failures = Vec::new();
210
211 let repo_settings = repo_root.join(".claude").join("settings.json");
212 if let Err(e) = setup_dev_allowlist(extra, &repo_settings) {
213 failures.push((repo_settings, e));
214 }
215
216 for target in alt_settings {
217 if target.parent().is_some_and(std::path::Path::is_dir)
222 && let Err(e) = setup_dev_allowlist(extra, target)
223 {
224 failures.push((target.clone(), e));
225 }
226 }
227
228 failures
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use tempfile::TempDir;
235
236 fn read_array(path: &Path) -> Vec<String> {
237 let raw = std::fs::read_to_string(path).unwrap();
238 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
239 v.get("allowed_bash_prefixes")
240 .and_then(|v| v.as_array())
241 .map(|arr| {
242 arr.iter()
243 .filter_map(|x| x.as_str().map(String::from))
244 .collect()
245 })
246 .unwrap_or_default()
247 }
248
249 #[test]
250 fn writes_preset_when_file_absent() {
251 let tmp = TempDir::new().unwrap();
252 let path = tmp.path().join("settings.json");
253 setup_dev_allowlist(&[], &path).unwrap();
254 let entries = read_array(&path);
255 for pat in DEV_ALLOWLIST_PRESET {
256 assert!(
257 entries.iter().any(|e| e == pat),
258 "missing preset pattern {pat:?} in {entries:?}",
259 );
260 }
261 }
262
263 #[test]
264 fn merges_with_existing_user_entries() {
265 let tmp = TempDir::new().unwrap();
266 let path = tmp.path().join("settings.json");
267 std::fs::write(
268 &path,
269 r#"{"some_custom_field":"value","allowed_bash_prefixes":["my-tool","some-other"]}"#,
270 )
271 .unwrap();
272 setup_dev_allowlist(&[], &path).unwrap();
273 let raw = std::fs::read_to_string(&path).unwrap();
274 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
275 assert_eq!(
276 v.get("some_custom_field").and_then(|x| x.as_str()),
277 Some("value"),
278 "must preserve unrelated top-level fields",
279 );
280 let entries = read_array(&path);
281 assert!(entries.iter().any(|e| e == "my-tool"));
282 assert!(entries.iter().any(|e| e == "some-other"));
283 for pat in DEV_ALLOWLIST_PRESET {
284 assert!(entries.iter().any(|e| e == pat), "missing {pat}");
285 }
286 }
287
288 #[test]
289 fn does_not_duplicate_existing_preset_entries() {
290 let tmp = TempDir::new().unwrap();
291 let path = tmp.path().join("settings.json");
292 std::fs::write(
293 &path,
294 r#"{"allowed_bash_prefixes":["cargo build","git push"]}"#,
295 )
296 .unwrap();
297 setup_dev_allowlist(&[], &path).unwrap();
298 let entries = read_array(&path);
299 assert_eq!(entries.iter().filter(|e| *e == "cargo build").count(), 1);
300 assert_eq!(entries.iter().filter(|e| *e == "git push").count(), 1);
301 }
302
303 #[test]
304 fn appends_extra_patterns_after_preset() {
305 let tmp = TempDir::new().unwrap();
306 let path = tmp.path().join("settings.json");
307 let extra = vec!["pnpm test".to_string(), "deno fmt".to_string()];
308 setup_dev_allowlist(&extra, &path).unwrap();
309 let entries = read_array(&path);
310 assert!(entries.iter().any(|e| e == "pnpm test"));
311 assert!(entries.iter().any(|e| e == "deno fmt"));
312 let pnpm_idx = entries.iter().position(|e| e == "pnpm test").unwrap();
313 let last_preset_idx = entries
314 .iter()
315 .rposition(|e| DEV_ALLOWLIST_PRESET.contains(&e.as_str()))
316 .unwrap();
317 assert!(
318 pnpm_idx > last_preset_idx,
319 "extra entries must follow the preset; entries: {entries:?}",
320 );
321 }
322
323 #[test]
324 fn extra_entries_not_validated() {
325 let tmp = TempDir::new().unwrap();
326 let path = tmp.path().join("settings.json");
327 let extra = vec!["this is nonsense $$".to_string()];
328 setup_dev_allowlist(&extra, &path).unwrap();
329 let entries = read_array(&path);
330 assert!(entries.iter().any(|e| e == "this is nonsense $$"));
331 }
332
333 #[test]
334 fn extra_duplicates_preset_entry_not_added_twice() {
335 let tmp = TempDir::new().unwrap();
336 let path = tmp.path().join("settings.json");
337 let extra = vec!["cargo build".to_string()];
338 setup_dev_allowlist(&extra, &path).unwrap();
339 let entries = read_array(&path);
340 assert_eq!(
341 entries.iter().filter(|e| *e == "cargo build").count(),
342 1,
343 "cargo build appears more than once: {entries:?}",
344 );
345 }
346
347 #[test]
348 fn invalid_json_returns_error_not_panic() {
349 let tmp = TempDir::new().unwrap();
350 let path = tmp.path().join("settings.json");
351 std::fs::write(&path, "not json {{{").unwrap();
352 let err = setup_dev_allowlist(&[], &path).unwrap_err();
353 let msg = err.to_string();
354 assert!(msg.contains("invalid JSON"), "got: {msg}");
355 let raw = std::fs::read_to_string(&path).unwrap();
357 assert_eq!(raw, "not json {{{");
358 }
359
360 #[test]
361 fn creates_parent_directory_when_missing() {
362 let tmp = TempDir::new().unwrap();
363 let path = tmp.path().join(".claude").join("settings.json");
364 assert!(!path.parent().unwrap().exists());
365 setup_dev_allowlist(&[], &path).unwrap();
366 assert!(path.exists());
367 }
368
369 #[test]
370 fn preset_constant_contains_all_required_patterns_and_no_excluded_ones() {
371 let required = [
372 "cargo build",
373 "cargo test",
374 "cargo clippy",
375 "cargo fmt",
376 "cargo check",
377 "cargo tree",
378 "cargo deny",
379 "cargo update",
380 "git status",
381 "git log",
382 "git diff",
383 "git show",
384 "git fetch",
385 "git commit",
386 "git push",
387 "git pull",
388 "git merge",
389 "git stash",
390 "git add",
391 "git restore",
392 "git rm",
393 "just",
394 "mdbook build",
395 "openspec validate",
396 "openspec new",
397 "openspec archive",
398 "openspec list",
399 "openspec status",
400 "openspec instructions",
401 "find",
402 "grep",
403 "sed -n",
404 ];
405 for r in required {
406 assert!(
407 DEV_ALLOWLIST_PRESET.contains(&r),
408 "preset missing required pattern: {r}",
409 );
410 }
411
412 let excluded = [
413 "cargo install",
414 "cargo run",
415 "cargo bench",
416 "git rebase",
417 "git reset",
418 "git checkout",
419 "git branch -D",
420 "git push --force",
421 "git push -f",
422 "sed",
423 "npm",
424 "pnpm",
425 "yarn",
426 "deno",
427 "bun",
428 "uv",
429 "pip",
430 "pipx",
431 "gem",
432 ];
433 for e in excluded {
434 assert!(
435 !DEV_ALLOWLIST_PRESET.contains(&e),
436 "preset must not contain excluded pattern: {e}",
437 );
438 }
439 }
440
441 #[test]
442 fn effective_patterns_orders_preset_before_extra() {
443 let extra = vec!["pnpm test".to_string()];
444 let out = effective_patterns(&extra);
445 let pnpm_idx = out.iter().position(|s| s == "pnpm test").unwrap();
446 let cargo_idx = out.iter().position(|s| s == "cargo build").unwrap();
447 assert!(
448 cargo_idx < pnpm_idx,
449 "preset entries must precede extra: cargo@{cargo_idx} vs pnpm@{pnpm_idx}",
450 );
451 }
452
453 #[test]
454 fn effective_patterns_deduplicates_extra_against_preset() {
455 let extra = vec!["cargo build".to_string()];
456 let out = effective_patterns(&extra);
457 assert_eq!(out.iter().filter(|s| *s == "cargo build").count(), 1);
458 }
459
460 #[test]
461 fn rejects_top_level_array() {
462 let tmp = TempDir::new().unwrap();
463 let path = tmp.path().join("settings.json");
464 std::fs::write(&path, "[]").unwrap();
465 let err = setup_dev_allowlist(&[], &path).unwrap_err();
466 let msg = err.to_string();
467 assert!(msg.contains("must be a JSON object"), "got: {msg}");
468 }
469}