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(
202 extra: &[String],
203 repo_root: &Path,
204) -> Vec<(std::path::PathBuf, PawError)> {
205 let mut failures = Vec::new();
206
207 let repo_settings = repo_root.join(".claude").join("settings.json");
208 if let Err(e) = setup_dev_allowlist(extra, &repo_settings) {
209 failures.push((repo_settings, e));
210 }
211
212 if let Some(home) = crate::dirs::home_dir() {
213 let claude_oss_dir = home.join(".claude-oss");
214 if claude_oss_dir.is_dir() {
215 let oss_settings = claude_oss_dir.join("settings.json");
216 if let Err(e) = setup_dev_allowlist(extra, &oss_settings) {
217 failures.push((oss_settings, e));
218 }
219 }
220 }
221
222 failures
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use tempfile::TempDir;
229
230 fn read_array(path: &Path) -> Vec<String> {
231 let raw = std::fs::read_to_string(path).unwrap();
232 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
233 v.get("allowed_bash_prefixes")
234 .and_then(|v| v.as_array())
235 .map(|arr| {
236 arr.iter()
237 .filter_map(|x| x.as_str().map(String::from))
238 .collect()
239 })
240 .unwrap_or_default()
241 }
242
243 #[test]
244 fn writes_preset_when_file_absent() {
245 let tmp = TempDir::new().unwrap();
246 let path = tmp.path().join("settings.json");
247 setup_dev_allowlist(&[], &path).unwrap();
248 let entries = read_array(&path);
249 for pat in DEV_ALLOWLIST_PRESET {
250 assert!(
251 entries.iter().any(|e| e == pat),
252 "missing preset pattern {pat:?} in {entries:?}",
253 );
254 }
255 }
256
257 #[test]
258 fn merges_with_existing_user_entries() {
259 let tmp = TempDir::new().unwrap();
260 let path = tmp.path().join("settings.json");
261 std::fs::write(
262 &path,
263 r#"{"some_custom_field":"value","allowed_bash_prefixes":["my-tool","some-other"]}"#,
264 )
265 .unwrap();
266 setup_dev_allowlist(&[], &path).unwrap();
267 let raw = std::fs::read_to_string(&path).unwrap();
268 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
269 assert_eq!(
270 v.get("some_custom_field").and_then(|x| x.as_str()),
271 Some("value"),
272 "must preserve unrelated top-level fields",
273 );
274 let entries = read_array(&path);
275 assert!(entries.iter().any(|e| e == "my-tool"));
276 assert!(entries.iter().any(|e| e == "some-other"));
277 for pat in DEV_ALLOWLIST_PRESET {
278 assert!(entries.iter().any(|e| e == pat), "missing {pat}");
279 }
280 }
281
282 #[test]
283 fn does_not_duplicate_existing_preset_entries() {
284 let tmp = TempDir::new().unwrap();
285 let path = tmp.path().join("settings.json");
286 std::fs::write(
287 &path,
288 r#"{"allowed_bash_prefixes":["cargo build","git push"]}"#,
289 )
290 .unwrap();
291 setup_dev_allowlist(&[], &path).unwrap();
292 let entries = read_array(&path);
293 assert_eq!(entries.iter().filter(|e| *e == "cargo build").count(), 1);
294 assert_eq!(entries.iter().filter(|e| *e == "git push").count(), 1);
295 }
296
297 #[test]
298 fn appends_extra_patterns_after_preset() {
299 let tmp = TempDir::new().unwrap();
300 let path = tmp.path().join("settings.json");
301 let extra = vec!["pnpm test".to_string(), "deno fmt".to_string()];
302 setup_dev_allowlist(&extra, &path).unwrap();
303 let entries = read_array(&path);
304 assert!(entries.iter().any(|e| e == "pnpm test"));
305 assert!(entries.iter().any(|e| e == "deno fmt"));
306 let pnpm_idx = entries.iter().position(|e| e == "pnpm test").unwrap();
307 let last_preset_idx = entries
308 .iter()
309 .rposition(|e| DEV_ALLOWLIST_PRESET.contains(&e.as_str()))
310 .unwrap();
311 assert!(
312 pnpm_idx > last_preset_idx,
313 "extra entries must follow the preset; entries: {entries:?}",
314 );
315 }
316
317 #[test]
318 fn extra_entries_not_validated() {
319 let tmp = TempDir::new().unwrap();
320 let path = tmp.path().join("settings.json");
321 let extra = vec!["this is nonsense $$".to_string()];
322 setup_dev_allowlist(&extra, &path).unwrap();
323 let entries = read_array(&path);
324 assert!(entries.iter().any(|e| e == "this is nonsense $$"));
325 }
326
327 #[test]
328 fn extra_duplicates_preset_entry_not_added_twice() {
329 let tmp = TempDir::new().unwrap();
330 let path = tmp.path().join("settings.json");
331 let extra = vec!["cargo build".to_string()];
332 setup_dev_allowlist(&extra, &path).unwrap();
333 let entries = read_array(&path);
334 assert_eq!(
335 entries.iter().filter(|e| *e == "cargo build").count(),
336 1,
337 "cargo build appears more than once: {entries:?}",
338 );
339 }
340
341 #[test]
342 fn invalid_json_returns_error_not_panic() {
343 let tmp = TempDir::new().unwrap();
344 let path = tmp.path().join("settings.json");
345 std::fs::write(&path, "not json {{{").unwrap();
346 let err = setup_dev_allowlist(&[], &path).unwrap_err();
347 let msg = err.to_string();
348 assert!(msg.contains("invalid JSON"), "got: {msg}");
349 let raw = std::fs::read_to_string(&path).unwrap();
351 assert_eq!(raw, "not json {{{");
352 }
353
354 #[test]
355 fn creates_parent_directory_when_missing() {
356 let tmp = TempDir::new().unwrap();
357 let path = tmp.path().join(".claude").join("settings.json");
358 assert!(!path.parent().unwrap().exists());
359 setup_dev_allowlist(&[], &path).unwrap();
360 assert!(path.exists());
361 }
362
363 #[test]
364 fn preset_constant_contains_all_required_patterns_and_no_excluded_ones() {
365 let required = [
366 "cargo build",
367 "cargo test",
368 "cargo clippy",
369 "cargo fmt",
370 "cargo check",
371 "cargo tree",
372 "cargo deny",
373 "cargo update",
374 "git status",
375 "git log",
376 "git diff",
377 "git show",
378 "git fetch",
379 "git commit",
380 "git push",
381 "git pull",
382 "git merge",
383 "git stash",
384 "git add",
385 "git restore",
386 "git rm",
387 "just",
388 "mdbook build",
389 "openspec validate",
390 "openspec new",
391 "openspec archive",
392 "openspec list",
393 "openspec status",
394 "openspec instructions",
395 "find",
396 "grep",
397 "sed -n",
398 ];
399 for r in required {
400 assert!(
401 DEV_ALLOWLIST_PRESET.contains(&r),
402 "preset missing required pattern: {r}",
403 );
404 }
405
406 let excluded = [
407 "cargo install",
408 "cargo run",
409 "cargo bench",
410 "git rebase",
411 "git reset",
412 "git checkout",
413 "git branch -D",
414 "git push --force",
415 "git push -f",
416 "sed",
417 "npm",
418 "pnpm",
419 "yarn",
420 "deno",
421 "bun",
422 "uv",
423 "pip",
424 "pipx",
425 "gem",
426 ];
427 for e in excluded {
428 assert!(
429 !DEV_ALLOWLIST_PRESET.contains(&e),
430 "preset must not contain excluded pattern: {e}",
431 );
432 }
433 }
434
435 #[test]
436 fn effective_patterns_orders_preset_before_extra() {
437 let extra = vec!["pnpm test".to_string()];
438 let out = effective_patterns(&extra);
439 let pnpm_idx = out.iter().position(|s| s == "pnpm test").unwrap();
440 let cargo_idx = out.iter().position(|s| s == "cargo build").unwrap();
441 assert!(
442 cargo_idx < pnpm_idx,
443 "preset entries must precede extra: cargo@{cargo_idx} vs pnpm@{pnpm_idx}",
444 );
445 }
446
447 #[test]
448 fn effective_patterns_deduplicates_extra_against_preset() {
449 let extra = vec!["cargo build".to_string()];
450 let out = effective_patterns(&extra);
451 assert_eq!(out.iter().filter(|s| *s == "cargo build").count(), 1);
452 }
453
454 #[test]
455 fn rejects_top_level_array() {
456 let tmp = TempDir::new().unwrap();
457 let path = tmp.path().join("settings.json");
458 std::fs::write(&path, "[]").unwrap();
459 let err = setup_dev_allowlist(&[], &path).unwrap_err();
460 let msg = err.to_string();
461 assert!(msg.contains("must be a JSON object"), "got: {msg}");
462 }
463}