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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+22
-17
@@ -68,7 +68,9 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
||||
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<Value> {
|
||||
(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."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+15
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user