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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user