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.
191/// - `~/.claude-oss/settings.json` — written only when the
192///   `~/.claude-oss/` directory already exists at session start
193///   (the alt-config dogfood pattern from `prompt-submit-fix`).
194///   The directory is never created by this function.
195///
196/// Each target is processed independently and failures are reported
197/// individually via the returned [`Vec`]; callers (e.g.
198/// `cmd_supervisor`) treat the per-target result as non-fatal and
199/// log warnings to stderr while continuing session start. Returns
200/// the empty vec on full success.
201pub 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        // File left unchanged.
350        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}