From b743de5f34e4c0c66cf726ce96450d958c890cf5 Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Mon, 20 Jul 2026 12:01:21 +0200 Subject: [PATCH] =?UTF-8?q?Sicherheitsrunde=20II:=20PTY-Label,=20Tool-Gren?= =?UTF-8?q?zen,=20atomare=20Schreibvorg=C3=A4nge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - term_start nur noch für das eigene term--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. --- src-tauri/src/domain/archive.rs | 4 +- src-tauri/src/domain/mod.rs | 20 +++++++ src-tauri/src/domain/paths.rs | 16 ++++-- src-tauri/src/domain/pool.rs | 12 ++--- src-tauri/src/domain/project.rs | 92 +++++++++++++++++++++++++++++--- src-tauri/src/domain/registry.rs | 2 +- src-tauri/src/domain/settings.rs | 2 +- src-tauri/src/domain/todo.rs | 2 +- src-tauri/src/mcp.rs | 39 ++++++++------ src-tauri/src/platform/unix.rs | 22 ++++++-- src-tauri/src/terminal.rs | 14 +++++ src/commands-view.ts | 18 +++++-- 12 files changed, 196 insertions(+), 47 deletions(-) diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index 0d91ae1..e7e725c 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -92,7 +92,7 @@ pub(crate) fn add_archive_permission( 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")) } /// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die @@ -128,7 +128,7 @@ fn remove_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result< allow.retain(|p| p.as_str() != Some(&edit)); } 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")) } /// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder. diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 551e415..a78b586 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -18,6 +18,26 @@ pub(crate) mod watcher; #[cfg(test)] pub(crate) mod testutil; +/// Datei atomar ersetzen: temp-Datei im Zielverzeichnis, dann rename. Ein +/// Absturz oder eine volle Platte hinterlässt nie eine abgeschnittene +/// Zieldatei — wichtig für Dateien, die der App nicht allein gehören +/// (.claude/settings.json der Projekte, claudes .claude.json, Registry). +pub(crate) fn write_atomic(path: &std::path::Path, content: &str) -> Result<(), String> { + let dir = path + .parent() + .ok_or_else(|| format!("{}: kein Elternordner", path.display()))?; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("{}: kein Dateiname", path.display()))?; + let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id())); + std::fs::write(&tmp, content).map_err(|e| format!("{}: {e}", tmp.display()))?; + std::fs::rename(&tmp, path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + format!("{}: {e}", path.display()) + }) +} + /// Namensprüfung für Projekte und Pool-Anzeigenamen. /// /// Der Name wird als Pfadsegment verwendet (Pool-Verzeichnis, Panel-Dateien) diff --git a/src-tauri/src/domain/paths.rs b/src-tauri/src/domain/paths.rs index 8078478..6715ff6 100644 --- a/src-tauri/src/domain/paths.rs +++ b/src-tauri/src/domain/paths.rs @@ -50,10 +50,18 @@ impl Paths { } } +/// Zweite Linie: Die Projekt-ID wird zum Dateinamen der Panel-Kanäle — ein +/// Wert mit Pfad-Bestandteilen (etwa aus einer verfälschten Registry oder +/// AI_CONTROL_PROJECT-Env) bricht hier laut ab, statt einen Pfad zu bilden. +fn checked(project: &str) -> &str { + crate::domain::check_name(project).unwrap(); + project +} + /// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als /// AI_CONTROL_PANEL in der PTY-Umgebung. pub(crate) fn panel_file(project: &str) -> PathBuf { - Paths::real().panels_dir().join(format!("{project}.md")) + Paths::real().panels_dir().join(format!("{}.md", checked(project))) } /// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim @@ -62,7 +70,7 @@ pub(crate) fn panel_file(project: &str) -> PathBuf { pub(crate) fn commands_file(project: &str) -> PathBuf { Paths::real() .panels_dir() - .join(format!("{project}.commands.jsonl")) + .join(format!("{}.commands.jsonl", checked(project))) } /// Suchtreffer-Datei eines Projekts (JSON, letzter search_archive-Aufruf — @@ -71,7 +79,7 @@ pub(crate) fn commands_file(project: &str) -> PathBuf { pub(crate) fn search_file(project: &str) -> PathBuf { Paths::real() .panels_dir() - .join(format!("{project}.search.json")) + .join(format!("{}.search.json", checked(project))) } /// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes @@ -80,7 +88,7 @@ pub(crate) fn search_file(project: &str) -> PathBuf { pub(crate) fn wiki_file(project: &str) -> PathBuf { Paths::real() .panels_dir() - .join(format!("{project}.wiki.json")) + .join(format!("{}.wiki.json", checked(project))) } /// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert. diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs index 80a0fdc..c6ebf2a 100644 --- a/src-tauri/src/domain/pool.rs +++ b/src-tauri/src/domain/pool.rs @@ -143,8 +143,7 @@ fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<( credential_type: credential_type.to_string(), }; let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?; - fs::write(dir.join(POOL_FILE), raw + "\n") - .map_err(|e| format!("{}: {e}", dir.join(POOL_FILE).display())) + crate::domain::write_atomic(&dir.join(POOL_FILE), &(raw + "\n")) } /// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json @@ -169,8 +168,7 @@ pub(crate) fn init_pool_config( } fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; - fs::write(dir.join("settings.json"), raw + "\n") - .map_err(|e| format!("{}: {e}", dir.join("settings.json").display()))?; + crate::domain::write_atomic(&dir.join("settings.json"), &(raw + "\n"))?; let claude_md = dir.join("CLAUDE.md"); if !claude_md.is_file() { fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?; @@ -245,7 +243,7 @@ fn ensure_panel_permission(pool_dir: &std::path::Path) { return; // nichts geändert } if let Ok(out) = serde_json::to_string_pretty(&v) { - let _ = fs::write(&sp, out + "\n"); + let _ = crate::domain::write_atomic(&sp, &(out + "\n")); } } @@ -294,7 +292,7 @@ fn register_mcp_server(pool_dir: &std::path::Path) { servers.remove("aicontrol"); // alter Key vor Umbenennung servers.insert(PANEL_MCP_SERVER.into(), desired); if let Ok(out) = serde_json::to_string_pretty(&v) { - let _ = fs::write(&cfg, out + "\n"); + let _ = crate::domain::write_atomic(&cfg, &(out + "\n")); } } @@ -477,7 +475,7 @@ pub(crate) fn set_apikey_in( settings["apiKeyHelper"] = serde_json::json!(crate::platform::apikey_helper_command(&dir, pool)); let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; - fs::write(&settings_path, raw + "\n").map_err(|e| format!("{}: {e}", settings_path.display())) + crate::domain::write_atomic(&settings_path, &(raw + "\n")) } pub(crate) fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> { diff --git a/src-tauri/src/domain/project.rs b/src-tauri/src/domain/project.rs index 0510034..c5de90a 100644 --- a/src-tauri/src/domain/project.rs +++ b/src-tauri/src/domain/project.rs @@ -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 { + 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(); diff --git a/src-tauri/src/domain/registry.rs b/src-tauri/src/domain/registry.rs index 6006684..518c4b3 100644 --- a/src-tauri/src/domain/registry.rs +++ b/src-tauri/src/domain/registry.rs @@ -72,7 +72,7 @@ pub(crate) fn save_registry( fs::create_dir_all(paths.config_dir()) .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; let file = paths.projects_file(); - fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display())) + crate::domain::write_atomic(&file, &(raw + "\n")) } /// Ordner eines registrierten Projekts. diff --git a/src-tauri/src/domain/settings.rs b/src-tauri/src/domain/settings.rs index c287969..6eb10af 100644 --- a/src-tauri/src/domain/settings.rs +++ b/src-tauri/src/domain/settings.rs @@ -73,7 +73,7 @@ fn write_app_setting( fs::create_dir_all(paths.config_dir()) .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display())) + crate::domain::write_atomic(&path, &(raw + "\n")) } #[cfg(test)] diff --git a/src-tauri/src/domain/todo.rs b/src-tauri/src/domain/todo.rs index 3ea4a05..9b76900 100644 --- a/src-tauri/src/domain/todo.rs +++ b/src-tauri/src/domain/todo.rs @@ -102,7 +102,7 @@ pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> 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")) } #[cfg(test)] diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 78514eb..7c602e2 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -68,7 +68,9 @@ fn handle(method: &str, req: &Value) -> Option { ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \ aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \ IMMER `path` statt `text` übergeben — der Server liest die Datei \ - selbst von der Platte, ohne dass ihr Inhalt generiert werden muss.", + selbst von der Platte, ohne dass ihr Inhalt generiert werden muss. \ + `path` muss im Projekt-, Arbeits- oder Archiv-Ordner liegen \ + (reguläre Datei, max. 2 MB).", "inputSchema": { "type": "object", "properties": { @@ -136,18 +138,11 @@ fn handle(method: &str, req: &Value) -> Option { (Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \ `description` und `tags` mitgeben — einmalige Kuratierung im Moment \ des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \ - konfiguriert, den Zielpfad im Argument `dir` mitgeben (der Nutzer \ - nennt ihn); ohne `dir` und ohne konfiguriertes Home meldet das Tool \ - das zurück.", + konfiguriert, meldet das Tool das zurück; gesetzt wird es in den \ + Projekt-Einstellungen oder im Panel.", "inputSchema": { "type": "object", "properties": { - "dir": { - "type": "string", - "description": - "Optionales Archiv-Home (absoluter Pfad). Wird gesetzt und für \ - künftige Archivierungen gemerkt.", - }, "folder": { "type": "string", "description": @@ -253,11 +248,18 @@ fn call_write(req: &Value) -> Value { let args = &req["params"]["arguments"]; // `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne // dass das Modell den Inhalt Token für Token als `text` generieren muss. + // Der Zugriff ist auf Projekt-, Arbeits- und Archiv-Ordner begrenzt + // (read_for_panel_in): das Tool ist promptfrei freigegeben und darf das + // Read-Permission-Modell von Claude Code nicht umgehen. let (text, ok_msg) = match args["path"].as_str() { - Some(src) => match std::fs::read_to_string(src) { - Ok(content) => (content, "Datei ins Panel geladen."), - Err(e) => return err(format!("Datei {src} nicht lesbar: {e}")), - }, + Some(src) => { + let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); + let paths = crate::domain::paths::Paths::real(); + match crate::domain::project::read_for_panel_in(&paths, &project, src) { + Ok(content) => (content, "Datei ins Panel geladen."), + Err(e) => return err(format!("Datei nicht geladen: {e}")), + } + } None => ( args["text"].as_str().unwrap_or("").to_string(), "Entwurf ins Panel geschrieben.", @@ -323,10 +325,13 @@ fn call_show_commands() -> Value { } } +/// `dir` gibt es hier bewusst nicht mehr: Das Setzen des Archiv-Homes vergibt +/// über `add_archive_permission` dauerhafte Rechte in der settings.json des +/// Projekts (additionalDirectories + Edit-Allow) — das bleibt der UI mit +/// Nutzer-Dialog vorbehalten, nicht einem Tool-Argument des Modells. fn call_archive(req: &Value) -> Value { let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); let args = &req["params"]["arguments"]; - let dir = args["dir"].as_str(); let meta = crate::domain::archive::ArchiveMeta { folder: args["folder"].as_str().map(str::to_string), description: args["description"].as_str().map(str::to_string), @@ -335,10 +340,10 @@ fn call_archive(req: &Value) -> Value { .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) .unwrap_or_default(), }; - match crate::domain::archive::archive_panel_content(&project, dir, &meta) { + match crate::domain::archive::archive_panel_content(&project, None, &meta) { Ok(path) => ok(format!("Archiviert: {}", path.display())), Err(e) => err(format!( - "Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen." + "Nicht archiviert: {e}. Archiv-Ordner im Panel oder in den Projekt-Einstellungen wählen." )), } } diff --git a/src-tauri/src/platform/unix.rs b/src-tauri/src/platform/unix.rs index c135229..4f6bdb7 100644 --- a/src-tauri/src/platform/unix.rs +++ b/src-tauri/src/platform/unix.rs @@ -1,6 +1,5 @@ //! Unix-Gemeinsames (macOS + Linux): Prozesse, Shell, Dateirechte, Symlinks. -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -45,12 +44,29 @@ pub(crate) fn kill_terminal(pid: u32) -> Result<(), String> { } } +/// Secret-Datei von Anfang an mit 0600 anlegen: `create_new` + Modus statt +/// schreiben-und-nachträglich-abdichten — so gibt es kein Fenster, in dem die +/// Datei mit umask-Rechten lesbar ist, und ein untergeschobener Symlink wird +/// nicht gefolgt (die alte Datei wird vorher entfernt, `create_new` lehnt +/// Vorhandenes ab). pub(crate) fn write_secret_file(path: &Path, content: &str) -> Result<(), String> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; } - std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + match std::fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("{}: {e}", path.display())), + } + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .map_err(|e| format!("{}: {e}", path.display()))?; + f.write_all(content.as_bytes()) .map_err(|e| format!("{}: {e}", path.display())) } diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index c1e198e..c0afd50 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -102,6 +102,12 @@ pub fn build_window( /// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem /// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. Die Login-Shell aus $SHELL (-l) /// baut den PATH aus ihren Profil-Dateien auf — shell-agnostisch. +/// +/// Nur das Terminal-Fenster selbst darf seine PTY starten: Die Terminals-Map +/// ist über das Fensterlabel adressiert, und `term_write` schreibt danach in +/// die laufende Shell. Ohne die Label-Prüfung könnte auch das abgelöste +/// Panel-Fenster — und damit jedes Skript in dessen Webview — eine Shell +/// starten und beschreiben. #[tauri::command] pub fn term_start( window: tauri::WebviewWindow, @@ -110,6 +116,9 @@ pub fn term_start( rows: u16, cols: u16, ) -> Result<(), String> { + if window.label() != format!("term-{project}") { + return Err(format!("PTY nur für das Terminal-Fenster: {}", window.label())); + } let paths = Paths::real(); let cwd = project_dir(&paths, &project)?; @@ -143,6 +152,11 @@ pub fn term_start( let mut cmd = crate::platform::shell_command(&claude_command(&paths)); cmd.cwd(&cwd); + // Aus der App-Umgebung geerbte Anthropic-Credentials rausnehmen — ein + // ANTHROPIC_API_KEY sticht sonst den apiKeyHelper des Pools. Variablen, die + // erst das Shell-Profil der Login-Shell setzt, erreicht das nicht. + cmd.env_remove("ANTHROPIC_API_KEY"); + cmd.env_remove("ANTHROPIC_AUTH_TOKEN"); cmd.env("TERM", "xterm-256color"); cmd.env("AI_CONTROL_PANEL", &panel_path); cmd.env("AI_CONTROL_COMMANDS", &commands_path); diff --git a/src/commands-view.ts b/src/commands-view.ts index bb8496c..eba1bb2 100644 --- a/src/commands-view.ts +++ b/src/commands-view.ts @@ -7,6 +7,13 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager"; + +/// Entfernt Bidi- und Zero-Width-Steuerzeichen (U+200B–200F, U+202A–202E, +/// U+2060–2064, U+2066–2069, U+FEFF) aus Befehlstexten. +function stripInvisibles(s: string): string { + return s.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g, ""); +} + interface CommandEntry { cmd: string; note?: string; @@ -106,7 +113,7 @@ export function initCommandsView( all.className = "cmd-all"; all.textContent = "Alle kopieren"; all.addEventListener("click", async () => { - await writeText(cmds.map((c) => c.cmd).join("\n")); + await writeText(cmds.map((c) => stripInvisibles(c.cmd)).join("\n")); flash(all, "copied"); }); head.append(all); @@ -114,13 +121,18 @@ export function initCommandsView( block.append(head); cmds.forEach((entry, j) => { + // Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND + // Kopie halten — sonst sieht der Nutzer einen anderen Befehl, als + // die Zwischenablage enthält. Der Lösch-Abgleich läuft weiter über + // den Original-String. + const visible = stripInvisibles(entry.cmd); const tile = document.createElement("div"); tile.className = "cmd-tile"; const body = document.createElement("div"); body.className = "cmd-body"; const cmd = document.createElement("div"); cmd.className = "cmd-text"; - cmd.textContent = entry.cmd; + cmd.textContent = visible; body.append(cmd); if (entry.note) { const note = document.createElement("div"); @@ -128,7 +140,7 @@ export function initCommandsView( note.textContent = entry.note; body.append(note); } - tile.append(body, copyBtn(() => entry.cmd), deleteBtn(() => onDelete(i, j, entry.cmd))); + tile.append(body, copyBtn(() => visible), deleteBtn(() => onDelete(i, j, entry.cmd))); block.append(tile); }); container.append(block);