Skip to main content

git_paw/supervisor/
dev_allowlist.rs

1//! Common dev-command allowlist seeding for the supervisor.
2//!
3//! Implements the `dev-command-allowlist` capability of the
4//! `common-dev-allowlist-preset` change: write a curated preset of
5//! prefix patterns (`cargo build`, `git commit`, `just`, `mdbook
6//! build`, `openspec validate`, `find`, `grep`, `sed -n`, ...) into
7//! `.claude/settings.json::allowed_bash_prefixes` so the supervisor
8//! does not hand-approve every dev-loop command variant.
9//!
10//! The preset is hard-coded in [`DEV_ALLOWLIST_PRESET`] (one source
11//! of truth, reviewable in PRs). Users extend it through
12//! `[supervisor.common_dev_allowlist] extra = [...]` in the repo
13//! config. The merge semantics are identical to
14//! [`crate::supervisor::curl_allowlist`]: existing entries are
15//! preserved, missing entries are appended, no duplicates are
16//! written, and the parent directory is created when missing.
17
18use std::path::Path;
19
20use crate::error::PawError;
21
22/// Common dev-loop prefix patterns seeded into Claude's
23/// `allowed_bash_prefixes` on supervisor start.
24///
25/// Inclusion rubric (see `design.md` D3): observed as a repeated
26/// prompt source in the v0.5.0 dogfood; bounded side-effects (no
27/// arbitrary network or arbitrary code execution); aligns with
28/// CLAUDE.md's git-safety protocol. Destructive operations
29/// (`cargo install`, `cargo run`, `git rebase`, `git reset`,
30/// `git checkout`, `git push --force`, write-mode `sed`, and
31/// non-cargo package managers) are intentionally excluded.
32///
33/// The constant is the single source of truth — no other location
34/// in the codebase may hard-code these patterns.
35pub const DEV_ALLOWLIST_PRESET: &[&str] = &[
36    // Cargo (read + build + test, no install/run/bench).
37    "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 read-only.
46    "git status",
47    "git log",
48    "git diff",
49    "git show",
50    "git fetch",
51    // Git write (non-destructive; rebase/reset/checkout excluded).
52    "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 (any recipe).
61    "just",
62    // mdBook.
63    "mdbook build",
64    // OpenSpec.
65    "openspec validate",
66    "openspec new",
67    "openspec archive",
68    "openspec list",
69    "openspec status",
70    "openspec instructions",
71    // Search (read-only; `sed -n` is the read-only invocation).
72    "find",
73    "grep",
74    "sed -n",
75];
76
77/// Returns the effective ordered preset list with `extra` patterns
78/// appended after the built-in preset.
79///
80/// Entries already present in [`DEV_ALLOWLIST_PRESET`] are skipped
81/// when found in `extra` so the caller does not produce duplicates
82/// before reaching the file-merge step. The preset slice is returned
83/// in declaration order; `extra` entries follow in their input order.
84#[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
98/// Merges the dev-allowlist preset + `extra` patterns into the JSON
99/// file at `settings_path`.
100///
101/// Behaviour mirrors [`crate::supervisor::curl_allowlist::setup_curl_allowlist`]:
102///
103/// - When `settings_path` does not exist, a fresh JSON object is
104///   created with `allowed_bash_prefixes` set to
105///   [`effective_patterns`] applied to `extra`.
106/// - When the file exists with valid JSON, existing fields are
107///   preserved unchanged and missing entries are appended to the
108///   `allowed_bash_prefixes` array.
109/// - When the file exists but is not a JSON object (or
110///   `allowed_bash_prefixes` is not an array), an error is returned
111///   and the file is left unchanged.
112/// - Parent directories are created when missing.
113/// - The function never panics.
114///
115/// # Errors
116///
117/// Returns [`PawError::ConfigError`] when the file cannot be read,
118/// contains invalid JSON, has a non-object top level, has a
119/// non-array `allowed_bash_prefixes`, or cannot be written back.
120pub 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
185/// Seeds the dev allowlist into every Claude settings target a
186/// supervisor session needs.
187///
188/// Targets:
189///
190/// - `<repo>/.claude/settings.json` — always written (its parent
191///   `<repo>/.claude/` is created if absent).
192/// - each path in `alt_settings` — a configured alternate settings
193///   file (resolved from `[clis.<name>].settings_path`). These are
194///   written only when their parent directory already exists; a
195///   target whose parent is absent is skipped, never created. The
196///   target set is config-driven — there is no hardcoded CLI name or
197///   path.
198///
199/// Each target is processed independently and failures are reported
200/// individually via the returned [`Vec`]; callers (e.g.
201/// `cmd_supervisor`) treat the per-target result as non-fatal and
202/// log warnings to stderr while continuing session start. Returns
203/// the empty vec on full success.
204pub 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        // Defence-in-depth: skip a target whose parent directory does not
218        // exist so the seeder never creates an alternate config dir (the
219        // caller's resolver already filters on this, but `setup_dev_allowlist`
220        // would otherwise `create_dir_all` the parent).
221        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        // File left unchanged.
356        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}