Skip to main content

thoughts_tool/utils/
claude_settings.rs

1use anyhow::Context;
2use anyhow::Result;
3use anyhow::anyhow;
4use anyhow::bail;
5use atomicwrites::AtomicFile;
6use atomicwrites::OverwriteBehavior;
7use colored::Colorize;
8use serde_json::Value;
9use serde_json::json;
10use std::collections::HashSet;
11use std::fs;
12use std::fs::OpenOptions;
13use std::io::Write;
14use std::path::Path;
15use std::path::PathBuf;
16use std::time::SystemTime;
17use std::time::UNIX_EPOCH;
18
19#[derive(Debug, Clone)]
20pub struct InjectionSummary {
21    pub settings_path: PathBuf,
22    pub added_additional_dirs: Vec<PathBuf>,
23    pub added_allow_rules: Vec<String>,
24    pub already_present_additional_dirs: Vec<PathBuf>,
25    pub already_present_allow_rules: Vec<String>,
26    pub warn_conflicting_denies: Vec<String>,
27}
28
29/// Inject Claude Code permissions using the additionalDirectories mechanism and
30/// narrow relative allow patterns. Works for both worktrees and regular repos.
31/// - Adds canonical(repo_root/.thoughts-data) to permissions.additionalDirectories
32/// - Adds three allow rules: Read(thoughts/**), Read(context/**), Read(references/**)
33/// - Atomic, idempotent, and never panics on malformed JSON (quarantines instead)
34pub fn inject_additional_directories(repo_root: &Path) -> Result<InjectionSummary> {
35    let settings_path = get_local_settings_path(repo_root);
36    ensure_parent_dir(&settings_path)?;
37
38    // Resolve .thoughts-data canonical path; fallback to non-canonical on error
39    let td = repo_root.join(".thoughts-data");
40    let canonical_thoughts_data = match fs::canonicalize(&td) {
41        Ok(p) => p,
42        Err(e) => {
43            eprintln!(
44                "{}: Failed to canonicalize {} ({}). Falling back to non-canonical path.",
45                "Warning".yellow(),
46                td.display(),
47                e
48            );
49            td
50        }
51    };
52
53    let ReadOutcome {
54        mut value,
55        had_valid_json,
56    } = read_or_init_settings(&settings_path)?;
57
58    // Ensure permissions scaffold (including additionalDirectories and allow arrays)
59    ensure_permissions_scaffold(&mut value);
60
61    // Prepare to track changes
62    let mut added_additional_dirs = Vec::new();
63    let mut already_present_additional_dirs = Vec::new();
64    let mut added_allow_rules = Vec::new();
65    let mut already_present_allow_rules = Vec::new();
66
67    // Work with additionalDirectories and allow in a nested scope to avoid borrow conflicts
68    {
69        let permissions = value
70            .get_mut("permissions")
71            .ok_or_else(|| anyhow!("permissions key missing after scaffold — this is a bug"))?;
72
73        ensure_array_field(permissions, "additionalDirectories", &settings_path)?;
74
75        let add_dirs = permissions["additionalDirectories"]
76            .as_array_mut()
77            .ok_or_else(|| {
78                anyhow!("additionalDirectories is not an array after scaffold — this is a bug")
79            })?;
80
81        // Build existing set for deduplication
82        let mut existing_add_dirs: HashSet<String> = add_dirs
83            .iter()
84            .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
85            .collect();
86
87        // 1) Insert canonical .thoughts-data path into additionalDirectories
88        let dir_str = canonical_thoughts_data.to_string_lossy().to_string();
89        if existing_add_dirs.contains(&dir_str) {
90            already_present_additional_dirs.push(canonical_thoughts_data);
91        } else {
92            add_dirs.push(Value::String(dir_str.clone()));
93            existing_add_dirs.insert(dir_str);
94            added_additional_dirs.push(canonical_thoughts_data);
95        }
96    }
97
98    // Now work with allow rules in a separate scope
99    let warn_conflicting_denies = {
100        let permissions = value
101            .get_mut("permissions")
102            .ok_or_else(|| anyhow!("permissions key missing after scaffold — this is a bug"))?;
103
104        ensure_array_field(permissions, "allow", &settings_path)?;
105
106        let allow = permissions["allow"]
107            .as_array_mut()
108            .ok_or_else(|| anyhow!("allow is not an array after scaffold — this is a bug"))?;
109
110        // Build existing set for deduplication
111        let mut existing_allow: HashSet<String> = allow
112            .iter()
113            .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
114            .collect();
115
116        // 2) Insert narrow relative allow rules
117        let required_rules = vec![
118            "Read(thoughts/**)".to_string(),
119            "Read(context/**)".to_string(),
120            "Read(references/**)".to_string(),
121        ];
122
123        for r in required_rules {
124            if existing_allow.contains(&r) {
125                already_present_allow_rules.push(r);
126            } else {
127                allow.push(Value::String(r.clone()));
128                existing_allow.insert(r.clone());
129                added_allow_rules.push(r);
130            }
131        }
132
133        // Best-effort conflict detection (exact string matches)
134        collect_conflicting_denies(permissions, &existing_allow)
135    };
136
137    // Only write if something changed
138    if !added_additional_dirs.is_empty() || !added_allow_rules.is_empty() {
139        if had_valid_json && settings_path.exists() {
140            backup_valid_to_bak(&settings_path).with_context(|| {
141                format!("Failed to create backup for {}", settings_path.display())
142            })?;
143        }
144        let serialized = serde_json::to_string_pretty(&value)
145            .context("Failed to serialize Claude settings JSON")?;
146
147        AtomicFile::new(&settings_path, OverwriteBehavior::AllowOverwrite)
148            .write(|f| f.write_all(serialized.as_bytes()))
149            .with_context(|| format!("Failed to write {}", settings_path.display()))?;
150    }
151
152    // Best-effort prune at end of operation to keep directory tidy
153    if let Err(e) = prune_malformed_backups(&settings_path, 3) {
154        eprintln!(
155            "{}: Failed to prune malformed Claude backups: {}",
156            "Warning".yellow(),
157            e
158        );
159    }
160    Ok(InjectionSummary {
161        settings_path,
162        added_additional_dirs,
163        added_allow_rules,
164        already_present_additional_dirs,
165        already_present_allow_rules,
166        warn_conflicting_denies,
167    })
168}
169
170fn get_local_settings_path(repo_root: &Path) -> PathBuf {
171    repo_root.join(".claude").join("settings.local.json")
172}
173
174fn ensure_parent_dir(settings_path: &Path) -> Result<()> {
175    if let Some(parent) = settings_path.parent() {
176        fs::create_dir_all(parent)
177            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
178    }
179    Ok(())
180}
181
182struct ReadOutcome {
183    value: Value,
184    had_valid_json: bool,
185}
186
187fn read_or_init_settings(settings_path: &Path) -> Result<ReadOutcome> {
188    if !settings_path.exists() {
189        return Ok(ReadOutcome {
190            value: json!({}),
191            had_valid_json: false,
192        });
193    }
194
195    let raw = fs::read_to_string(settings_path)
196        .with_context(|| format!("Failed to read {}", settings_path.display()))?;
197
198    if let Ok(value) = serde_json::from_str::<Value>(&raw) {
199        Ok(ReadOutcome {
200            value,
201            had_valid_json: true,
202        })
203    } else {
204        // Malformed JSON: quarantine and start fresh
205        let malformed =
206            quarantine_malformed_settings(settings_path, &raw, current_malformed_backup_suffix())?;
207        eprintln!(
208            "{}: Existing Claude settings were malformed. Quarantined to {}",
209            "Warning".yellow(),
210            malformed.display()
211        );
212        // Best-effort prune after quarantine
213        if let Err(e) = prune_malformed_backups(settings_path, 3) {
214            eprintln!(
215                "{}: Failed to prune malformed Claude backups: {}",
216                "Warning".yellow(),
217                e
218            );
219        }
220        Ok(ReadOutcome {
221            value: json!({}),
222            had_valid_json: false,
223        })
224    }
225}
226
227fn current_malformed_backup_suffix() -> u64 {
228    SystemTime::now()
229        .duration_since(UNIX_EPOCH)
230        .unwrap_or_default()
231        .as_nanos()
232        .try_into()
233        .unwrap_or(u64::MAX)
234}
235
236fn quarantine_malformed_settings(
237    settings_path: &Path,
238    raw: &str,
239    initial_suffix: u64,
240) -> Result<PathBuf> {
241    let mut suffix = initial_suffix;
242    loop {
243        let malformed = settings_path.with_extension(format!("json.malformed.{suffix}.bak"));
244        match OpenOptions::new()
245            .write(true)
246            .create_new(true)
247            .open(&malformed)
248        {
249            Ok(mut file) => {
250                file.write_all(raw.as_bytes()).with_context(|| {
251                    format!(
252                        "Failed to write quarantined malformed Claude settings {}",
253                        malformed.display()
254                    )
255                })?;
256                drop(file);
257                fs::remove_file(settings_path).with_context(|| {
258                    format!(
259                        "Failed to remove malformed Claude settings {} after quarantine",
260                        settings_path.display()
261                    )
262                })?;
263                return Ok(malformed);
264            }
265            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
266                suffix = suffix.checked_add(1).ok_or_else(|| {
267                    anyhow!(
268                        "Exhausted malformed backup suffixes for {}",
269                        settings_path.display()
270                    )
271                })?;
272            }
273            Err(e) => {
274                return Err(e).with_context(|| {
275                    format!(
276                        "Failed to reserve malformed Claude settings quarantine path {}",
277                        malformed.display()
278                    )
279                });
280            }
281        }
282    }
283}
284
285/// Ensure permissions, allow, deny, ask scaffolding exists without overwriting unrelated keys.
286fn ensure_permissions_scaffold(root: &mut Value) {
287    if !root.is_object() {
288        *root = json!({});
289    }
290    if !root
291        .get("permissions")
292        .is_some_and(serde_json::Value::is_object)
293    {
294        root["permissions"] = json!({});
295    }
296    if root["permissions"].get("deny").is_none() {
297        root["permissions"]["deny"] = json!([]);
298    }
299    if root["permissions"].get("ask").is_none() {
300        root["permissions"]["ask"] = json!([]);
301    }
302}
303
304fn ensure_array_field(permissions: &mut Value, key: &str, settings_path: &Path) -> Result<()> {
305    match permissions.get(key) {
306        None => {
307            permissions[key] = json!([]);
308            Ok(())
309        }
310        Some(value) if value.is_array() => Ok(()),
311        Some(_) => bail!(
312            "permissions.{key} must be an array in {}",
313            settings_path.display()
314        ),
315    }
316}
317
318fn backup_valid_to_bak(settings_path: &Path) -> Result<()> {
319    let bak = settings_path.with_extension("json.bak");
320    fs::copy(settings_path, &bak).with_context(|| {
321        format!(
322            "Failed to copy {} -> {}",
323            settings_path.display(),
324            bak.display()
325        )
326    })?;
327    Ok(())
328}
329
330fn collect_conflicting_denies(permissions: &Value, allow_set: &HashSet<String>) -> Vec<String> {
331    let mut conflicts = Vec::new();
332    if let Some(deny) = permissions.get("deny").and_then(|d| d.as_array()) {
333        for d in deny {
334            if let Some(ds) = d.as_str()
335                && allow_set.contains(ds)
336            {
337                conflicts.push(ds.to_string());
338            }
339        }
340    }
341    conflicts
342}
343
344fn prune_malformed_backups(settings_path: &Path, keep: usize) -> Result<usize> {
345    let dir = settings_path
346        .parent()
347        .ok_or_else(|| anyhow!("Missing parent dir for settings"))?;
348    let prefix = "settings.local.json.malformed.";
349    let suffix = ".bak";
350    let mut entries: Vec<(u64, PathBuf)> = Vec::new();
351    for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
352        let p = entry?.path();
353        let Some(name_os) = p.file_name() else {
354            continue;
355        };
356        let name = name_os.to_string_lossy();
357        if !name.starts_with(prefix) || !name.ends_with(suffix) {
358            continue;
359        }
360        let ts_str = &name[prefix.len()..name.len() - suffix.len()];
361        if let Ok(ts) = ts_str.parse::<u64>() {
362            entries.push((ts, p));
363        }
364    }
365    // Sort newest first
366    entries.sort_by_key(|(ts, _)| *ts);
367    entries.reverse();
368    let mut deleted = 0usize;
369    for (_, p) in entries.into_iter().skip(keep) {
370        match fs::remove_file(&p) {
371            Ok(()) => deleted += 1,
372            Err(e) => eprintln!(
373                "{}: Failed to remove old malformed backup {}: {}",
374                "Warning".yellow(),
375                p.display(),
376                e
377            ),
378        }
379    }
380    Ok(deleted)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use tempfile::TempDir;
387
388    #[test]
389    fn creates_file_and_adds_additional_dir_and_rules() {
390        let td = TempDir::new().unwrap();
391        let repo = td.path();
392
393        // Create .thoughts-data to allow canonicalization
394        let td_path = repo.join(".thoughts-data");
395        fs::create_dir_all(&td_path).unwrap();
396
397        let summary = inject_additional_directories(repo).unwrap();
398
399        // Path correctness
400        assert!(
401            summary
402                .settings_path
403                .ends_with(".claude/settings.local.json")
404        );
405
406        // Should have added both: at least one additional dir and all rules
407        assert_eq!(summary.added_additional_dirs.len(), 1);
408        assert_eq!(summary.added_allow_rules.len(), 3);
409
410        let content = fs::read_to_string(&summary.settings_path).unwrap();
411        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
412        let add_dirs = json["permissions"]["additionalDirectories"]
413            .as_array()
414            .unwrap();
415        let allow = json["permissions"]["allow"].as_array().unwrap();
416
417        // Verify contents
418        let add_dirs_strs: Vec<&str> = add_dirs.iter().filter_map(|v| v.as_str()).collect();
419        assert_eq!(add_dirs_strs.len(), 1);
420        assert!(add_dirs_strs[0].ends_with("/.thoughts-data"));
421
422        let allow_strs: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
423        assert!(allow_strs.contains(&"Read(thoughts/**)"));
424        assert!(allow_strs.contains(&"Read(context/**)"));
425        assert!(allow_strs.contains(&"Read(references/**)"));
426    }
427
428    #[test]
429    fn idempotent_no_duplicates() {
430        let td = TempDir::new().unwrap();
431        let repo = td.path();
432        fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
433
434        let _ = inject_additional_directories(repo).unwrap();
435        let again = inject_additional_directories(repo).unwrap();
436
437        assert!(again.added_additional_dirs.is_empty());
438        assert!(again.added_allow_rules.is_empty());
439
440        let content = fs::read_to_string(&again.settings_path).unwrap();
441        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
442        let allow = json["permissions"]["allow"].as_array().unwrap();
443
444        let mut seen = std::collections::HashSet::new();
445        for item in allow {
446            if let Some(s) = item.as_str() {
447                assert!(seen.insert(s.to_string()), "Duplicate found: {s}");
448            }
449        }
450    }
451
452    #[test]
453    fn malformed_settings_is_quarantined() {
454        let td = TempDir::new().unwrap();
455        let repo = td.path();
456        fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
457
458        let settings = repo.join(".claude").join("settings.local.json");
459        fs::create_dir_all(settings.parent().unwrap()).unwrap();
460        fs::write(&settings, "not-json").unwrap();
461
462        let summary = inject_additional_directories(repo).unwrap();
463        assert!(summary.settings_path.exists());
464
465        // Look for quarantine
466        let dir = settings.parent().unwrap();
467        let entries = fs::read_dir(dir).unwrap();
468        let mut found_malformed = false;
469        for e in entries {
470            let p = e.unwrap().path();
471            let name = p.file_name().unwrap().to_string_lossy();
472            if name.contains("settings.local.json.malformed.") {
473                found_malformed = true;
474                break;
475            }
476        }
477        assert!(found_malformed);
478    }
479
480    #[test]
481    fn quarantine_uses_next_numeric_suffix_when_backup_exists() {
482        let td = TempDir::new().unwrap();
483        let settings = td.path().join(".claude").join("settings.local.json");
484        fs::create_dir_all(settings.parent().unwrap()).unwrap();
485        fs::write(&settings, "second").unwrap();
486
487        let existing = settings.with_extension("json.malformed.42.bak");
488        fs::write(&existing, "first").unwrap();
489
490        let malformed = super::quarantine_malformed_settings(&settings, "second", 42).unwrap();
491
492        assert_eq!(malformed, settings.with_extension("json.malformed.43.bak"));
493        assert_eq!(fs::read_to_string(&existing).unwrap(), "first");
494        assert_eq!(fs::read_to_string(&malformed).unwrap(), "second");
495        assert!(!settings.exists());
496    }
497
498    #[test]
499    fn backup_valid_before_write() {
500        let td = TempDir::new().unwrap();
501        let repo = td.path();
502        fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
503
504        let settings = repo.join(".claude").join("settings.local.json");
505        fs::create_dir_all(settings.parent().unwrap()).unwrap();
506        fs::write(
507            &settings,
508            r#"{"permissions":{"allow":[],"deny":[],"ask":[]}}"#,
509        )
510        .unwrap();
511
512        let _ = inject_additional_directories(repo).unwrap();
513        let bak = settings.with_extension("json.bak");
514        assert!(bak.exists());
515    }
516
517    #[test]
518    fn rejects_non_array_additional_directories() {
519        let td = TempDir::new().unwrap();
520        let repo = td.path();
521        fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
522
523        let settings = repo.join(".claude").join("settings.local.json");
524        fs::create_dir_all(settings.parent().unwrap()).unwrap();
525        fs::write(
526            &settings,
527            r#"{"permissions":{"additionalDirectories":"bad","allow":[],"deny":[],"ask":[]}}"#,
528        )
529        .unwrap();
530
531        let err = inject_additional_directories(repo).unwrap_err();
532
533        assert!(
534            err.to_string()
535                .contains("permissions.additionalDirectories must be an array")
536        );
537    }
538
539    #[test]
540    fn rejects_non_array_allow() {
541        let td = TempDir::new().unwrap();
542        let repo = td.path();
543        fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
544
545        let settings = repo.join(".claude").join("settings.local.json");
546        fs::create_dir_all(settings.parent().unwrap()).unwrap();
547        fs::write(
548            &settings,
549            r#"{"permissions":{"additionalDirectories":[],"allow":"bad","deny":[],"ask":[]}}"#,
550        )
551        .unwrap();
552
553        let err = inject_additional_directories(repo).unwrap_err();
554
555        assert!(
556            err.to_string()
557                .contains("permissions.allow must be an array")
558        );
559    }
560
561    #[test]
562    fn fallback_to_non_canonical_on_missing_path() {
563        let td = TempDir::new().unwrap();
564        let repo = td.path();
565        // Intentionally do NOT create .thoughts-data so canonicalize fails
566        let summary = inject_additional_directories(repo).unwrap();
567
568        let content = fs::read_to_string(&summary.settings_path).unwrap();
569        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
570        let add_dirs = json["permissions"]["additionalDirectories"]
571            .as_array()
572            .unwrap();
573        let add_dirs_strs: Vec<&str> = add_dirs.iter().filter_map(|v| v.as_str()).collect();
574        assert_eq!(add_dirs_strs.len(), 1);
575        assert!(add_dirs_strs[0].ends_with("/.thoughts-data"));
576    }
577
578    #[test]
579    fn prunes_to_last_three_malformed_backups() {
580        let td = TempDir::new().unwrap();
581        let repo = td.path();
582        let settings = repo.join(".claude").join("settings.local.json");
583        fs::create_dir_all(settings.parent().unwrap()).unwrap();
584
585        // Create 5 malformed backups with increasing timestamps
586        for ts in [100, 200, 300, 400, 500] {
587            let p = settings.with_extension(format!("json.malformed.{ts}.bak"));
588            fs::write(&p, b"{}").unwrap();
589        }
590
591        let deleted = super::prune_malformed_backups(&settings, 3).unwrap();
592        assert_eq!(deleted, 2);
593
594        let kept: Vec<u64> = fs::read_dir(settings.parent().unwrap())
595            .unwrap()
596            .filter_map(|e| {
597                let name = e.unwrap().file_name().to_string_lossy().into_owned();
598                if let Some(s) = name
599                    .strip_prefix("settings.local.json.malformed.")
600                    .and_then(|s| s.strip_suffix(".bak"))
601                {
602                    s.parse::<u64>().ok()
603                } else {
604                    None
605                }
606            })
607            .collect();
608
609        assert_eq!(kept.len(), 3);
610        assert!(kept.contains(&300) && kept.contains(&400) && kept.contains(&500));
611    }
612
613    #[test]
614    fn ignores_non_numeric_malformed_backups() {
615        let td = TempDir::new().unwrap();
616        let repo = td.path();
617        let settings = repo.join(".claude").join("settings.local.json");
618        fs::create_dir_all(settings.parent().unwrap()).unwrap();
619
620        // Badly named file should be ignored by prune
621        let bad = settings.with_extension("json.malformed.bad.bak");
622        fs::write(&bad, b"{}").unwrap();
623
624        let deleted = super::prune_malformed_backups(&settings, 3).unwrap();
625        assert_eq!(deleted, 0);
626        assert!(bad.exists());
627    }
628
629    #[test]
630    fn quarantine_then_prune_leaves_three() {
631        let td = TempDir::new().unwrap();
632        let repo = td.path();
633        fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
634
635        // Corrupt settings multiple times to force quarantine
636        for _ in 0..5 {
637            let settings = repo.join(".claude").join("settings.local.json");
638            fs::create_dir_all(settings.parent().unwrap()).unwrap();
639            fs::write(&settings, "not-json").unwrap();
640            let _ = inject_additional_directories(repo).unwrap();
641        }
642
643        // Count malformed backups (should be <= 3)
644        let dir = repo.join(".claude");
645        let count = fs::read_dir(&dir)
646            .unwrap()
647            .filter(|e| {
648                e.as_ref()
649                    .ok()
650                    .and_then(|x| {
651                        x.file_name()
652                            .to_str()
653                            .map(|s| s.contains("settings.local.json.malformed."))
654                    })
655                    .unwrap_or(false)
656            })
657            .count();
658        assert!(count <= 3);
659    }
660}