Sicherheitsrunde II: PTY-Label, Tool-Grenzen, atomare Schreibvorgänge

- term_start nur noch für das eigene term-<projekt>-Fenster — das
  (abgelöste) Panel-Fenster kann keine Shell mehr starten und beschreiben.
  Die Tauri-ACL greift für App-Commands nachweislich nicht (kein
  __app-acl__-Manifest, Prüfung gegen tauri 2.11.5); die Label-Prüfung
  ist damit die Schranke, nicht deren Verdopplung.
- archive_panel verliert das dir-Argument: Archiv-Home samt dauerhafter
  Rechtevergabe (additionalDirectories + Edit-Allow) setzt nur noch die
  UI mit Nutzer-Dialog, kein Tool-Argument des Modells.
- write_panel(path) liest nur noch aus Projekt-, Arbeits- und
  Archiv-Ordnern (canonicalize, symlink-fest), reguläre Dateien bis 2 MB
  mit hartem Byte-Cap — der promptfreie Weg umgeht das
  Read-Permission-Modell von Claude Code nicht mehr.
- write_atomic (temp + rename) für alle JSON-Config-Schreiber: Projekt-
  Config, .claude/settings.json, Registry, App-Settings, pool.json,
  Pool-settings.json, claudes .claude.json, Todo-Hook.
- write_secret_file: create_new mit 0600 statt schreiben-und-abdichten;
  kein umask-Fenster, Symlinks werden nicht gefolgt.
- PTY-Umgebung: geerbte ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN entfernt.
- panel_file & Geschwister prüfen die Projekt-ID (zweite Linie).
- Befehlskacheln filtern Bidi-/Zero-Width-Zeichen aus Anzeige und
  Zwischenablage; der Lösch-Abgleich behält den Originaltext.
This commit is contained in:
marcus hinz
2026-07-20 12:01:21 +02:00
parent 23f6562f50
commit b743de5f34
12 changed files with 196 additions and 47 deletions
+84 -8
View File
@@ -119,7 +119,7 @@ pub(crate) fn write_project_config_in(
let dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR);
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(PROJECT_FILE);
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
crate::domain::write_atomic(&path, &(raw + "\n"))
}
pub(crate) fn is_running(project: &str) -> bool {
@@ -292,8 +292,7 @@ pub(crate) fn create_project_full_in(
},
});
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(dir.join(".claude").join("settings.json"), raw + "\n")
.map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?;
crate::domain::write_atomic(&dir.join(".claude").join("settings.json"), &(raw + "\n"))?;
let id = uuid::Uuid::new_v4().to_string();
reg.insert(id.clone(), RegEntry { dir: dir.clone(), pool: pool.map(str::to_string) });
@@ -357,7 +356,7 @@ pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result
}
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Nimmt einen bestehenden Ordner als Projekt auf. Eine mitgebrachte
@@ -449,7 +448,7 @@ pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<()
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
@@ -475,7 +474,7 @@ pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result
allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
@@ -588,6 +587,49 @@ pub(crate) fn resolve_icon_path(
}
}
/// Liest eine Datei für `write_panel(path)` — den promptfreien MCP-Weg.
/// Erlaubt ist genau, was die Session über ihre Permissions ohnehin promptfrei
/// liest: Projektordner, Arbeitsordner und Archiv-Home. Geprüft wird nach
/// `canonicalize` (symlink-fest), nur reguläre Dateien bis 2 MB — sonst
/// umginge das Tool das Read-Permission-Modell von Claude Code, und ein
/// Device wie /dev/zero hinge den Prozess.
pub(crate) fn read_for_panel_in(
paths: &Paths,
project: &str,
src: &str,
) -> Result<String, String> {
const MAX: u64 = 2 * 1024 * 1024;
let canon = fs::canonicalize(expand_home(paths, src)).map_err(|e| format!("{src}: {e}"))?;
let mut roots = vec![project_dir(paths, project)?];
for d in project_work_dirs_in(paths, project)? {
roots.push(expand_home(paths, &d));
}
if let Some(a) = read_project_config_in(paths, project)?.archive_home {
roots.push(expand_home(paths, &a));
}
let allowed = roots
.iter()
.filter_map(|r| fs::canonicalize(r).ok())
.any(|r| canon.starts_with(&r));
if !allowed {
return Err(format!(
"{src}: liegt außerhalb von Projekt-, Arbeits- und Archiv-Ordnern"
));
}
let f = fs::File::open(&canon).map_err(|e| format!("{src}: {e}"))?;
let meta = f.metadata().map_err(|e| format!("{src}: {e}"))?;
if !meta.is_file() {
return Err(format!("{src}: keine reguläre Datei"));
}
if meta.len() > MAX {
return Err(format!("{src}: größer als 2 MB"));
}
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::Read::take(f, MAX), &mut text)
.map_err(|e| format!("{src}: {e}"))?;
Ok(text)
}
/// Migration beim App-Start (idempotent): ai-control.json aus dem Projekt-Root
/// nach .ai-control/config.json, die Pool-Zuordnung daraus in die Registry,
/// Projekt-Icons aus dem zentralen Icons-Verzeichnis in den Projekt-Config-Ordner.
@@ -615,7 +657,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
if !cfg_path.exists() && !obj.is_empty() {
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?;
crate::domain::write_atomic(&cfg_path, &(raw + "\n"))?;
}
fs::remove_file(&legacy).map_err(|e| format!("{}: {e}", legacy.display()))?;
}
@@ -660,7 +702,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
if cfg_dirty {
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?;
crate::domain::write_atomic(&cfg_path, &(raw + "\n"))?;
}
if id != key {
reg_dirty = true;
@@ -1200,6 +1242,40 @@ mod tests {
assert!(!raw.contains("claude-projects/proj"));
}
// -- write_panel(path): promptfreier Lese-Weg --
#[test]
fn panel_lesen_nur_aus_projekt_arbeits_und_archivordnern() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let dir = p.projects_dir().join("proj");
fs::write(dir.join("notiz.md"), "inhalt").unwrap();
assert_eq!(read_for_panel_in(&p, "proj", &dir.join("notiz.md").display().to_string()).unwrap(), "inhalt");
// außerhalb: abgelehnt
fs::write(p.home.join("geheim.txt"), "x").unwrap();
let err = read_for_panel_in(&p, "proj", &p.home.join("geheim.txt").display().to_string())
.unwrap_err();
assert!(err.contains("außerhalb"));
// Symlink aus dem Projekt hinaus: canonicalize entlarvt das Ziel
std::os::unix::fs::symlink(p.home.join("geheim.txt"), dir.join("link.txt")).unwrap();
assert!(read_for_panel_in(&p, "proj", &dir.join("link.txt").display().to_string()).is_err());
}
#[test]
fn panel_lesen_groessenlimit_und_geraetedateien() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let dir = p.projects_dir().join("proj");
fs::write(dir.join("gross.bin"), vec![b'x'; 2 * 1024 * 1024 + 1]).unwrap();
let err = read_for_panel_in(&p, "proj", &dir.join("gross.bin").display().to_string())
.unwrap_err();
assert!(err.contains("2 MB"));
// Geräte-Datei liegt eh außerhalb der Wurzeln — der Ablehnungsgrund davor
assert!(read_for_panel_in(&p, "proj", "/dev/zero").is_err());
}
#[test]
fn projekt_loeschen_unbekannt_scheitert() {
let p = tmp_paths();