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: write a curated
4//! preset of prefix patterns into
5//! `.claude/settings.json::allowed_bash_prefixes` so the supervisor
6//! does not hand-approve every dev-loop command variant.
7//!
8//! The preset is split into two tiers (see `design.md` D2):
9//!
10//! - [`DEV_ALLOWLIST_PRESET`] — the **universal** set, hard-coded and
11//!   always seeded. It contains only commands that are safe and useful
12//!   in essentially any repository regardless of language or toolchain
13//!   (non-destructive git verbs plus read-only `find` / `grep` /
14//!   `sed -n`). It is the single source of truth for the universal
15//!   tier — no other location may hard-code these patterns.
16//! - [`stack_preset`] / the named `*_STACK_PRESET` constants — curated,
17//!   opt-in **stack-specific** bundles (`rust` / `node` / `python` /
18//!   `go`). A repository opts in via
19//!   `[supervisor.common_dev_allowlist] stacks = ["rust", ...]`; the
20//!   seeder resolves the selected stacks to the union of the universal
21//!   preset, each selected stack, and any `extra` patterns.
22//!
23//! Users further extend the result through
24//! `[supervisor.common_dev_allowlist] extra = [...]`. The merge
25//! semantics are identical to [`crate::supervisor::curl_allowlist`]:
26//! existing entries are preserved, missing entries are appended, no
27//! duplicates are written, and the parent directory is created when
28//! missing.
29//!
30//! Every seeded value is a command **prefix** (a verb, or verb plus
31//! subcommand) that subsumes all per-invocation argument variations —
32//! e.g. `git diff` (which prefix-matches `git diff --stat HEAD~1`),
33//! never a fully-argumented command line. A prefix grant collapses the
34//! infinite set of per-run argument variations into one approval.
35
36use std::path::Path;
37
38use crate::error::PawError;
39
40/// Universal dev-loop prefix patterns seeded into Claude's
41/// `allowed_bash_prefixes` on supervisor start, independent of the
42/// repository's language or toolchain.
43///
44/// Inclusion rubric (see `design.md` D3): bounded side-effects (no
45/// arbitrary network or arbitrary code execution); aligns with
46/// CLAUDE.md's git-safety protocol. Destructive git operations
47/// (`git rebase`, `git reset`, `git checkout`, `git push --force`)
48/// and write-mode `sed` are intentionally excluded; stack-specific
49/// toolchain commands are NOT hard-coded here — they are opt-in via
50/// the named stack presets (see [`stack_preset`]) and/or `extra`.
51///
52/// The constant is the single source of truth for the universal tier
53/// — no other location in the codebase may hard-code these patterns.
54pub const DEV_ALLOWLIST_PRESET: &[&str] = &[
55    // Git read-only.
56    "git status",
57    "git log",
58    "git diff",
59    "git show",
60    "git fetch",
61    // Git write (non-destructive; rebase/reset/checkout excluded).
62    "git commit",
63    "git push",
64    "git pull",
65    "git merge",
66    "git stash",
67    "git add",
68    "git restore",
69    "git rm",
70    // Search (read-only; `sed -n` is the read-only invocation).
71    "find",
72    "grep",
73    "sed -n",
74];
75
76/// Curated `rust` stack preset (opt-in via `stacks = ["rust"]`).
77///
78/// Build/test/lint verbs only; `cargo install` / `cargo run` /
79/// `cargo bench` are excluded per the D3 rubric.
80pub 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
91/// Curated `node` stack preset (opt-in via `stacks = ["node"]`).
92///
93/// Package-manager build/test/install verbs only; `publish` /
94/// `uninstall` and arbitrary-script execution beyond the curated
95/// verbs are excluded per the D3 rubric.
96pub 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
108/// Curated `python` stack preset (opt-in via `stacks = ["python"]`).
109///
110/// Test/lint/format/dependency verbs only; arbitrary `python -c` and
111/// publish/upload verbs are excluded per the D3 rubric.
112pub 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
123/// Curated `go` stack preset (opt-in via `stacks = ["go"]`).
124///
125/// Build/test/vet/format verbs only; `go run` (arbitrary code
126/// execution) is excluded per the D3 rubric.
127pub 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/// Resolves a stack-preset name to its curated prefix list.
138///
139/// Returns the matching `*_STACK_PRESET` slice for a known name
140/// (`rust` / `node` / `python` / `go`), or `None` for an unrecognised
141/// name. Matching is case-sensitive against the lowercase names a
142/// repository declares in `[supervisor.common_dev_allowlist] stacks`.
143/// An unknown stack name contributes nothing (no error) so a typo or a
144/// future stack name in an older binary degrades gracefully.
145#[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/// Returns the effective ordered pattern list: the universal preset,
157/// followed by each selected stack preset, followed by `extra`,
158/// de-duplicated.
159///
160/// Resolution order (see `design.md` D2): the universal
161/// [`DEV_ALLOWLIST_PRESET`] first (declaration order), then each named
162/// preset from `stacks` in selection order (unknown names contribute
163/// nothing), then `extra` in input order. A pattern already present
164/// from an earlier tier is not added again, so the result is the
165/// de-duplicated union.
166#[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
190/// Merges the dev-allowlist preset + `extra` patterns into the JSON
191/// file at `settings_path`.
192///
193/// Behaviour mirrors [`crate::supervisor::curl_allowlist::setup_curl_allowlist`]:
194///
195/// - When `settings_path` does not exist, a fresh JSON object is
196///   created with `allowed_bash_prefixes` set to
197///   [`effective_patterns`] applied to `stacks` + `extra`.
198/// - When the file exists with valid JSON, existing fields are
199///   preserved unchanged and missing entries are appended to the
200///   `allowed_bash_prefixes` array.
201/// - When the file exists but is not a JSON object (or
202///   `allowed_bash_prefixes` is not an array), an error is returned
203///   and the file is left unchanged.
204/// - Parent directories are created when missing.
205/// - The function never panics.
206///
207/// # Errors
208///
209/// Returns [`PawError::ConfigError`] when the file cannot be read,
210/// contains invalid JSON, has a non-object top level, has a
211/// non-array `allowed_bash_prefixes`, or cannot be written back.
212pub 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
281/// Seeds the dev allowlist into every Claude settings target a
282/// supervisor session needs.
283///
284/// Targets:
285///
286/// - `<repo>/.claude/settings.json` — always written (its parent
287///   `<repo>/.claude/` is created if absent).
288/// - each path in `alt_settings` — a configured alternate settings
289///   file (resolved from `[clis.<name>].settings_path`). These are
290///   written only when their parent directory already exists; a
291///   target whose parent is absent is skipped, never created. The
292///   target set is config-driven — there is no hardcoded CLI name or
293///   path.
294///
295/// Each target is processed independently and failures are reported
296/// individually via the returned [`Vec`]; callers (e.g.
297/// `cmd_supervisor`) treat the per-target result as non-fatal and
298/// log warnings to stderr while continuing session start. Returns
299/// the empty vec on full success.
300pub 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        // Defence-in-depth: skip a target whose parent directory does not
315        // exist so the seeder never creates an alternate config dir (the
316        // caller's resolver already filters on this, but `setup_dev_allowlist`
317        // would otherwise `create_dir_all` the parent).
318        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        // File left unchanged.
453        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        // The universal preset SHALL contain exactly the stack-neutral
469        // git + search verbs and nothing else.
470        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        // The set is *exactly* the universal patterns — no extras crept in.
495        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        // Stack-specific patterns moved to named presets / `extra` SHALL
502        // NOT be hard-coded in the universal preset.
503        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        // Destructive patterns stay excluded from the universal set.
525        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        // No curated stack preset may carry a destructive / arbitrary-
545        // code-execution verb (design.md D3).
546        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        // Unknown stack names resolve to nothing (graceful degradation).
565        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        // Spot-check no stack leakage.
607        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        // Universal preset still present; ordering is universal-then-stack.
618        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        // No duplicates anywhere in the union.
641        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}