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:
2026-05-13 17:53:17 +01:00
parent cde985d0c1
commit a2b47db193

View File

@@ -429,6 +429,16 @@
let showNewProfile = $state(false); let showNewProfile = $state(false);
let showNewTemplate = $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 // Phase 9c: SettingsGroup native <details> drives disclosure state per
// group; no more centralised openSection. Transcription stays open by // group; no more centralised openSection. Transcription stays open by
// default (matches the prior accordion behaviour where it was the // default (matches the prior accordion behaviour where it was the
@@ -886,6 +896,10 @@
if (unlisten) unlisten(); if (unlisten) unlisten();
if (unlistenLlm) unlistenLlm(); if (unlistenLlm) unlistenLlm();
if (unlistenParakeet) unlistenParakeet(); if (unlistenParakeet) unlistenParakeet();
if (deleteProfileArmTimer) {
clearTimeout(deleteProfileArmTimer);
deleteProfileArmTimer = null;
}
}); });
$effect(() => { $effect(() => {
@@ -1025,8 +1039,55 @@
editingProfile = profiles.length - 1; 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; 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) { if (page.activeProfile === name) {
page.activeProfile = "None"; page.activeProfile = "None";
} }
@@ -1034,6 +1095,10 @@
profiles.splice(index, 1); profiles.splice(index, 1);
saveProfiles(); saveProfiles();
editingProfile = -1; editingProfile = -1;
} finally {
deleteProfileBusy = false;
disarmDeleteProfile();
}
} }
function profileWordCount(words) { function profileWordCount(words) {
@@ -1437,10 +1502,23 @@
class="text-[12px] text-text-secondary hover:text-accent opacity-0 group-hover:opacity-100" class="text-[12px] text-text-secondary hover:text-accent opacity-0 group-hover:opacity-100"
onclick={() => editingProfile = editingProfile === i ? -1 : i} onclick={() => editingProfile = editingProfile === i ? -1 : i}
>{editingProfile === i ? "Close" : "Edit"}</button> >{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 <button
class="text-[12px] text-text-secondary hover:text-danger opacity-0 group-hover:opacity-100" class="text-[12px] text-text-secondary hover:text-danger opacity-0 group-hover:opacity-100"
onclick={() => deleteProfile(i)} onclick={() => armDeleteProfile(i)}
>Delete</button> >Delete</button>
{/if}
</div> </div>
{#if editingProfile === i} {#if editingProfile === i}
<div class="mt-1.5 animate-fade-in"> <div class="mt-1.5 animate-fade-in">