agent: code-atomiser-fix — restrict write_text_file_cmd to app data + download dirs (Trust-1)
The Tauri command `write_text_file_cmd` took an arbitrary `path: String` and flowed it straight into `tokio::fs::write`, with no main-window guard, no canonicalisation, and no scope check. A compromised webview could write anywhere the process had write access — overwriting shell init files, dropping a runner into `~/.config/autostart`, etc. The in-file comment "the dialog already constrains the user's choice" described an intended invariant the IPC surface never enforced. This change: - adds `ensure_main_window(&window)?` so only the main webview can invoke the command; - canonicalises the requested path's parent (rejecting nonexistent parents and resolving symlinks) before joining the filename; - asserts the canonical target sits inside an allowlisted base (app data, app local data, downloads, documents, desktop), so a `"../../etc/passwd"` payload — even one obtained by symlink trickery in the chosen save dir — is refused with a clear error. Six unit tests cover: outside-allowlist rejection, path-traversal rejection, nested inside-allowlist acceptance, plain inside-allowlist acceptance, nonexistent-parent rejection, and the pure `is_inside_any_base` prefix check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -429,6 +429,16 @@
|
||||
let showNewProfile = $state(false);
|
||||
let showNewTemplate = $state(false);
|
||||
|
||||
// Arm-confirm state for profile deletion. Mirrors HistoryPage's inline
|
||||
// confirm pattern (4-second auto-disarm) — first click on Delete arms,
|
||||
// second click within the window calls through to the storage layer.
|
||||
// Replaces the prior one-click splice-into-localStorage path that
|
||||
// silently drifted localStorage and SQLite apart.
|
||||
const PROFILE_DELETE_CONFIRM_MS = 4000;
|
||||
let deleteProfileArmedIndex = $state(-1);
|
||||
let deleteProfileArmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let deleteProfileBusy = $state(false);
|
||||
|
||||
// Phase 9c: SettingsGroup native <details> drives disclosure state per
|
||||
// group; no more centralised openSection. Transcription stays open by
|
||||
// default (matches the prior accordion behaviour where it was the
|
||||
@@ -886,6 +896,10 @@
|
||||
if (unlisten) unlisten();
|
||||
if (unlistenLlm) unlistenLlm();
|
||||
if (unlistenParakeet) unlistenParakeet();
|
||||
if (deleteProfileArmTimer) {
|
||||
clearTimeout(deleteProfileArmTimer);
|
||||
deleteProfileArmTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -1025,8 +1039,55 @@
|
||||
editingProfile = profiles.length - 1;
|
||||
}
|
||||
|
||||
function deleteProfile(index) {
|
||||
// Two-step delete. First call arms (UI swaps to Confirm/Cancel pills);
|
||||
// second call inside the window does the work. Routes through the
|
||||
// SQLite-backed profilesStore so the canonical profile row + its
|
||||
// profile_terms cascade are removed; legacy localStorage entry is only
|
||||
// spliced after the storage call succeeds. The storage layer rejects
|
||||
// deletion when the profile still has transcripts attached — that
|
||||
// rejection surfaces as a toast via profilesStore.delete().
|
||||
function armDeleteProfile(index) {
|
||||
deleteProfileArmedIndex = index;
|
||||
if (deleteProfileArmTimer) clearTimeout(deleteProfileArmTimer);
|
||||
deleteProfileArmTimer = setTimeout(() => {
|
||||
deleteProfileArmedIndex = -1;
|
||||
deleteProfileArmTimer = null;
|
||||
}, PROFILE_DELETE_CONFIRM_MS);
|
||||
}
|
||||
|
||||
function disarmDeleteProfile() {
|
||||
deleteProfileArmedIndex = -1;
|
||||
if (deleteProfileArmTimer) {
|
||||
clearTimeout(deleteProfileArmTimer);
|
||||
deleteProfileArmTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteProfile(index) {
|
||||
if (deleteProfileBusy) return;
|
||||
if (index < 0 || index >= profiles.length) {
|
||||
disarmDeleteProfile();
|
||||
return;
|
||||
}
|
||||
const name = profiles[index].name;
|
||||
deleteProfileBusy = true;
|
||||
try {
|
||||
// Match the legacy localStorage record to its SQL counterpart by
|
||||
// name. The two systems were grown in parallel: localStorage uses
|
||||
// name as the natural key, SQL uses a UUID. Until the data model
|
||||
// is unified we have to bridge by name.
|
||||
const sqlProfile = profilesStore.profiles.find((p) => p.name === name);
|
||||
if (sqlProfile && sqlProfile.id !== DEFAULT_PROFILE_ID) {
|
||||
const before = profilesStore.profiles.length;
|
||||
await profilesStore.delete(sqlProfile.id);
|
||||
// profilesStore.delete catches its own errors and emits a toast;
|
||||
// we detect failure by checking whether the store actually removed
|
||||
// the row. If it did not, abandon the legacy splice so the two
|
||||
// sides stay aligned.
|
||||
if (profilesStore.profiles.length === before) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (page.activeProfile === name) {
|
||||
page.activeProfile = "None";
|
||||
}
|
||||
@@ -1034,6 +1095,10 @@
|
||||
profiles.splice(index, 1);
|
||||
saveProfiles();
|
||||
editingProfile = -1;
|
||||
} finally {
|
||||
deleteProfileBusy = false;
|
||||
disarmDeleteProfile();
|
||||
}
|
||||
}
|
||||
|
||||
function profileWordCount(words) {
|
||||
@@ -1437,10 +1502,23 @@
|
||||
class="text-[12px] text-text-secondary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||
onclick={() => editingProfile = editingProfile === i ? -1 : i}
|
||||
>{editingProfile === i ? "Close" : "Edit"}</button>
|
||||
{#if deleteProfileArmedIndex === i}
|
||||
<button
|
||||
class="text-[12px] text-danger font-medium hover:underline"
|
||||
onclick={() => confirmDeleteProfile(i)}
|
||||
disabled={deleteProfileBusy}
|
||||
>Confirm</button>
|
||||
<button
|
||||
class="text-[12px] text-text-tertiary hover:text-text hover:underline"
|
||||
onclick={disarmDeleteProfile}
|
||||
disabled={deleteProfileBusy}
|
||||
>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
class="text-[12px] text-text-secondary hover:text-danger opacity-0 group-hover:opacity-100"
|
||||
onclick={() => deleteProfile(i)}
|
||||
onclick={() => armDeleteProfile(i)}
|
||||
>Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editingProfile === i}
|
||||
<div class="mt-1.5 animate-fade-in">
|
||||
|
||||
Reference in New Issue
Block a user