1use std::path::Path;
37
38use crate::error::PawError;
39
40pub const DEV_ALLOWLIST_PRESET: &[&str] = &[
55 "git status",
57 "git log",
58 "git diff",
59 "git show",
60 "git fetch",
61 "git commit",
63 "git push",
64 "git pull",
65 "git merge",
66 "git stash",
67 "git add",
68 "git restore",
69 "git rm",
70 "find",
72 "grep",
73 "sed -n",
74];
75
76pub const RUST_STACK_PRESET: &[&str] = &[
81 "cargo build",
82 "cargo test",
83 "cargo clippy",
84 "cargo fmt",
85 "cargo check",
86 "cargo tree",
87 "cargo deny",
88 "cargo update",
89];
90
91pub const NODE_STACK_PRESET: &[&str] = &[
97 "npm install",
98 "npm ci",
99 "npm test",
100 "npm run",
101 "pnpm install",
102 "pnpm test",
103 "pnpm run",
104 "yarn install",
105 "yarn test",
106];
107
108pub const PYTHON_STACK_PRESET: &[&str] = &[
113 "pytest",
114 "pip install",
115 "ruff",
116 "black",
117 "mypy",
118 "flake8",
119 "uv pip",
120 "uv sync",
121];
122
123pub const GO_STACK_PRESET: &[&str] = &[
128 "go build",
129 "go test",
130 "go vet",
131 "go fmt",
132 "gofmt",
133 "go mod",
134 "golangci-lint",
135];
136
137#[must_use]
146pub fn stack_preset(name: &str) -> Option<&'static [&'static str]> {
147 match name {
148 "rust" => Some(RUST_STACK_PRESET),
149 "node" => Some(NODE_STACK_PRESET),
150 "python" => Some(PYTHON_STACK_PRESET),
151 "go" => Some(GO_STACK_PRESET),
152 _ => None,
153 }
154}
155
156#[must_use]
167pub fn effective_patterns(stacks: &[String], extra: &[String]) -> Vec<String> {
168 let mut out: Vec<String> = DEV_ALLOWLIST_PRESET
169 .iter()
170 .map(|s| (*s).to_string())
171 .collect();
172 let push_unique = |out: &mut Vec<String>, pat: &str| {
173 if !out.iter().any(|existing| existing == pat) {
174 out.push(pat.to_string());
175 }
176 };
177 for stack in stacks {
178 if let Some(preset) = stack_preset(stack) {
179 for pat in preset {
180 push_unique(&mut out, pat);
181 }
182 }
183 }
184 for entry in extra {
185 push_unique(&mut out, entry);
186 }
187 out
188}
189
190pub fn setup_dev_allowlist(
213 stacks: &[String],
214 extra: &[String],
215 settings_path: &Path,
216) -> Result<(), PawError> {
217 let new_entries = effective_patterns(stacks, extra);
218
219 let mut value: serde_json::Value = if settings_path.exists() {
220 let raw = std::fs::read_to_string(settings_path).map_err(|e| {
221 PawError::ConfigError(format!("failed to read {}: {e}", settings_path.display()))
222 })?;
223 if raw.trim().is_empty() {
224 serde_json::Value::Object(serde_json::Map::new())
225 } else {
226 serde_json::from_str(&raw).map_err(|e| {
227 PawError::ConfigError(format!("{}: invalid JSON: {e}", settings_path.display()))
228 })?
229 }
230 } else {
231 serde_json::Value::Object(serde_json::Map::new())
232 };
233
234 let obj = value.as_object_mut().ok_or_else(|| {
235 PawError::ConfigError(format!(
236 "{}: top-level value must be a JSON object",
237 settings_path.display()
238 ))
239 })?;
240
241 let entry = obj
242 .entry("allowed_bash_prefixes".to_string())
243 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
244
245 let array = entry.as_array_mut().ok_or_else(|| {
246 PawError::ConfigError(format!(
247 "{}: allowed_bash_prefixes must be an array",
248 settings_path.display()
249 ))
250 })?;
251
252 for new_entry in new_entries {
253 let already_present = array
254 .iter()
255 .any(|v| v.as_str().is_some_and(|s| s == new_entry));
256 if !already_present {
257 array.push(serde_json::Value::String(new_entry));
258 }
259 }
260
261 if let Some(parent) = settings_path.parent()
262 && !parent.as_os_str().is_empty()
263 {
264 std::fs::create_dir_all(parent).map_err(|e| {
265 PawError::ConfigError(format!("failed to create {}: {e}", parent.display()))
266 })?;
267 }
268
269 let serialized = serde_json::to_string_pretty(&value).map_err(|e| {
270 PawError::ConfigError(format!(
271 "failed to serialize {}: {e}",
272 settings_path.display()
273 ))
274 })?;
275 std::fs::write(settings_path, serialized).map_err(|e| {
276 PawError::ConfigError(format!("failed to write {}: {e}", settings_path.display()))
277 })?;
278 Ok(())
279}
280
281pub fn seed_supervisor_session(
301 stacks: &[String],
302 extra: &[String],
303 repo_root: &Path,
304 alt_settings: &[std::path::PathBuf],
305) -> Vec<(std::path::PathBuf, PawError)> {
306 let mut failures = Vec::new();
307
308 let repo_settings = repo_root.join(".claude").join("settings.json");
309 if let Err(e) = setup_dev_allowlist(stacks, extra, &repo_settings) {
310 failures.push((repo_settings, e));
311 }
312
313 for target in alt_settings {
314 if target.parent().is_some_and(std::path::Path::is_dir)
319 && let Err(e) = setup_dev_allowlist(stacks, extra, target)
320 {
321 failures.push((target.clone(), e));
322 }
323 }
324
325 failures
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use tempfile::TempDir;
332
333 fn read_array(path: &Path) -> Vec<String> {
334 let raw = std::fs::read_to_string(path).unwrap();
335 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
336 v.get("allowed_bash_prefixes")
337 .and_then(|v| v.as_array())
338 .map(|arr| {
339 arr.iter()
340 .filter_map(|x| x.as_str().map(String::from))
341 .collect()
342 })
343 .unwrap_or_default()
344 }
345
346 #[test]
347 fn writes_preset_when_file_absent() {
348 let tmp = TempDir::new().unwrap();
349 let path = tmp.path().join("settings.json");
350 setup_dev_allowlist(&[], &[], &path).unwrap();
351 let entries = read_array(&path);
352 for pat in DEV_ALLOWLIST_PRESET {
353 assert!(
354 entries.iter().any(|e| e == pat),
355 "missing preset pattern {pat:?} in {entries:?}",
356 );
357 }
358 }
359
360 #[test]
361 fn merges_with_existing_user_entries() {
362 let tmp = TempDir::new().unwrap();
363 let path = tmp.path().join("settings.json");
364 std::fs::write(
365 &path,
366 r#"{"some_custom_field":"value","allowed_bash_prefixes":["my-tool","some-other"]}"#,
367 )
368 .unwrap();
369 setup_dev_allowlist(&[], &[], &path).unwrap();
370 let raw = std::fs::read_to_string(&path).unwrap();
371 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
372 assert_eq!(
373 v.get("some_custom_field").and_then(|x| x.as_str()),
374 Some("value"),
375 "must preserve unrelated top-level fields",
376 );
377 let entries = read_array(&path);
378 assert!(entries.iter().any(|e| e == "my-tool"));
379 assert!(entries.iter().any(|e| e == "some-other"));
380 for pat in DEV_ALLOWLIST_PRESET {
381 assert!(entries.iter().any(|e| e == pat), "missing {pat}");
382 }
383 }
384
385 #[test]
386 fn does_not_duplicate_existing_preset_entries() {
387 let tmp = TempDir::new().unwrap();
388 let path = tmp.path().join("settings.json");
389 std::fs::write(
390 &path,
391 r#"{"allowed_bash_prefixes":["git diff","git push"]}"#,
392 )
393 .unwrap();
394 setup_dev_allowlist(&[], &[], &path).unwrap();
395 let entries = read_array(&path);
396 assert_eq!(entries.iter().filter(|e| *e == "git diff").count(), 1);
397 assert_eq!(entries.iter().filter(|e| *e == "git push").count(), 1);
398 }
399
400 #[test]
401 fn appends_extra_patterns_after_preset() {
402 let tmp = TempDir::new().unwrap();
403 let path = tmp.path().join("settings.json");
404 let extra = vec!["pnpm test".to_string(), "deno fmt".to_string()];
405 setup_dev_allowlist(&[], &extra, &path).unwrap();
406 let entries = read_array(&path);
407 assert!(entries.iter().any(|e| e == "pnpm test"));
408 assert!(entries.iter().any(|e| e == "deno fmt"));
409 let pnpm_idx = entries.iter().position(|e| e == "pnpm test").unwrap();
410 let last_preset_idx = entries
411 .iter()
412 .rposition(|e| DEV_ALLOWLIST_PRESET.contains(&e.as_str()))
413 .unwrap();
414 assert!(
415 pnpm_idx > last_preset_idx,
416 "extra entries must follow the preset; entries: {entries:?}",
417 );
418 }
419
420 #[test]
421 fn extra_entries_not_validated() {
422 let tmp = TempDir::new().unwrap();
423 let path = tmp.path().join("settings.json");
424 let extra = vec!["this is nonsense $$".to_string()];
425 setup_dev_allowlist(&[], &extra, &path).unwrap();
426 let entries = read_array(&path);
427 assert!(entries.iter().any(|e| e == "this is nonsense $$"));
428 }
429
430 #[test]
431 fn extra_duplicates_preset_entry_not_added_twice() {
432 let tmp = TempDir::new().unwrap();
433 let path = tmp.path().join("settings.json");
434 let extra = vec!["git diff".to_string()];
435 setup_dev_allowlist(&[], &extra, &path).unwrap();
436 let entries = read_array(&path);
437 assert_eq!(
438 entries.iter().filter(|e| *e == "git diff").count(),
439 1,
440 "git diff appears more than once: {entries:?}",
441 );
442 }
443
444 #[test]
445 fn invalid_json_returns_error_not_panic() {
446 let tmp = TempDir::new().unwrap();
447 let path = tmp.path().join("settings.json");
448 std::fs::write(&path, "not json {{{").unwrap();
449 let err = setup_dev_allowlist(&[], &[], &path).unwrap_err();
450 let msg = err.to_string();
451 assert!(msg.contains("invalid JSON"), "got: {msg}");
452 let raw = std::fs::read_to_string(&path).unwrap();
454 assert_eq!(raw, "not json {{{");
455 }
456
457 #[test]
458 fn creates_parent_directory_when_missing() {
459 let tmp = TempDir::new().unwrap();
460 let path = tmp.path().join(".claude").join("settings.json");
461 assert!(!path.parent().unwrap().exists());
462 setup_dev_allowlist(&[], &[], &path).unwrap();
463 assert!(path.exists());
464 }
465
466 #[test]
467 fn preset_constant_contains_only_universal_patterns() {
468 let required = [
471 "git status",
472 "git log",
473 "git diff",
474 "git show",
475 "git fetch",
476 "git commit",
477 "git push",
478 "git pull",
479 "git merge",
480 "git stash",
481 "git add",
482 "git restore",
483 "git rm",
484 "find",
485 "grep",
486 "sed -n",
487 ];
488 for r in required {
489 assert!(
490 DEV_ALLOWLIST_PRESET.contains(&r),
491 "universal preset missing required pattern: {r}",
492 );
493 }
494 assert_eq!(
496 DEV_ALLOWLIST_PRESET.len(),
497 required.len(),
498 "universal preset must contain exactly the required patterns; got {DEV_ALLOWLIST_PRESET:?}",
499 );
500
501 let stack_specific = [
504 "cargo build",
505 "cargo test",
506 "cargo clippy",
507 "cargo fmt",
508 "cargo check",
509 "just",
510 "mdbook build",
511 "openspec validate",
512 "openspec status",
513 "npm install",
514 "pytest",
515 "go build",
516 ];
517 for s in stack_specific {
518 assert!(
519 !DEV_ALLOWLIST_PRESET.contains(&s),
520 "universal preset must not contain stack-specific pattern: {s}",
521 );
522 }
523
524 let excluded = [
526 "git rebase",
527 "git reset",
528 "git checkout",
529 "git branch -D",
530 "git push --force",
531 "git push -f",
532 "sed",
533 ];
534 for e in excluded {
535 assert!(
536 !DEV_ALLOWLIST_PRESET.contains(&e),
537 "preset must not contain excluded pattern: {e}",
538 );
539 }
540 }
541
542 #[test]
543 fn curated_stack_presets_obey_the_exclusion_rubric() {
544 let forbidden = [
547 "cargo install",
548 "cargo run",
549 "cargo bench",
550 "go run",
551 "npm publish",
552 "npm uninstall",
553 "pip uninstall",
554 ];
555 for stack in ["rust", "node", "python", "go"] {
556 let preset = stack_preset(stack).expect("named stack resolves");
557 for f in forbidden {
558 assert!(
559 !preset.contains(&f),
560 "stack `{stack}` must not contain forbidden verb: {f}",
561 );
562 }
563 }
564 assert!(stack_preset("haskell").is_none());
566 }
567
568 #[test]
569 fn rust_stack_preset_carries_curated_cargo_verbs() {
570 let preset = stack_preset("rust").expect("rust stack resolves");
571 for pat in ["cargo build", "cargo test", "cargo clippy"] {
572 assert!(preset.contains(&pat), "rust stack missing {pat}");
573 }
574 }
575
576 #[test]
577 fn effective_patterns_orders_preset_before_extra() {
578 let extra = vec!["pnpm test".to_string()];
579 let out = effective_patterns(&[], &extra);
580 let pnpm_idx = out.iter().position(|s| s == "pnpm test").unwrap();
581 let git_idx = out.iter().position(|s| s == "git diff").unwrap();
582 assert!(
583 git_idx < pnpm_idx,
584 "preset entries must precede extra: git@{git_idx} vs pnpm@{pnpm_idx}",
585 );
586 }
587
588 #[test]
589 fn effective_patterns_deduplicates_extra_against_preset() {
590 let extra = vec!["git diff".to_string()];
591 let out = effective_patterns(&[], &extra);
592 assert_eq!(out.iter().filter(|s| *s == "git diff").count(), 1);
593 }
594
595 #[test]
596 fn effective_patterns_universal_only_when_no_stacks_or_extra() {
597 let out = effective_patterns(&[], &[]);
598 let expected: Vec<String> = DEV_ALLOWLIST_PRESET
599 .iter()
600 .map(|s| (*s).to_string())
601 .collect();
602 assert_eq!(
603 out, expected,
604 "no stacks + no extra must yield exactly the universal preset"
605 );
606 assert!(!out.iter().any(|s| s == "cargo build"));
608 }
609
610 #[test]
611 fn effective_patterns_rust_stack_adds_cargo_prefixes() {
612 let stacks = vec!["rust".to_string()];
613 let out = effective_patterns(&stacks, &[]);
614 for pat in RUST_STACK_PRESET {
615 assert!(out.iter().any(|s| s == pat), "missing rust prefix {pat}");
616 }
617 let git_idx = out.iter().position(|s| s == "git diff").unwrap();
619 let cargo_idx = out.iter().position(|s| s == "cargo build").unwrap();
620 assert!(git_idx < cargo_idx, "universal must precede stack prefixes");
621 }
622
623 #[test]
624 fn effective_patterns_node_stack_has_no_cargo() {
625 let stacks = vec!["node".to_string()];
626 let out = effective_patterns(&stacks, &[]);
627 assert!(out.iter().any(|s| s.starts_with("npm")));
628 assert!(
629 !out.iter().any(|s| s.starts_with("cargo")),
630 "node stack must not seed any cargo prefix: {out:?}",
631 );
632 }
633
634 #[test]
635 fn effective_patterns_multiple_stacks_compose_as_dedup_union() {
636 let stacks = vec!["rust".to_string(), "python".to_string()];
637 let out = effective_patterns(&stacks, &[]);
638 assert!(out.iter().any(|s| s == "cargo build"));
639 assert!(out.iter().any(|s| s == "pytest"));
640 let mut seen = std::collections::HashSet::new();
642 for s in &out {
643 assert!(seen.insert(s.clone()), "duplicate pattern in union: {s}");
644 }
645 }
646
647 #[test]
648 fn effective_patterns_unknown_stack_contributes_nothing() {
649 let stacks = vec!["haskell".to_string()];
650 let out = effective_patterns(&stacks, &[]);
651 let expected: Vec<String> = DEV_ALLOWLIST_PRESET
652 .iter()
653 .map(|s| (*s).to_string())
654 .collect();
655 assert_eq!(out, expected, "unknown stack must add nothing");
656 }
657
658 #[test]
659 fn rejects_top_level_array() {
660 let tmp = TempDir::new().unwrap();
661 let path = tmp.path().join("settings.json");
662 std::fs::write(&path, "[]").unwrap();
663 let err = setup_dev_allowlist(&[], &[], &path).unwrap_err();
664 let msg = err.to_string();
665 assert!(msg.contains("must be a JSON object"), "got: {msg}");
666 }
667}