fix(security): resolve all HIGH/MEDIUM/LOW audit findings
Rust fixes (HIGH): - symbols.rs: path validation (reject near-root, 50K file limit, symlink filter) - memory.rs: FTS5 query quoting (prevent operator injection), 1000 fragment cap, content length limit, transaction wrapping - budget.rs: atomic check-and-reserve via transaction, input validation, index on budget_log - export.rs: safe UTF-8 truncation via chars().take() - git_context.rs: reject paths starting with '-' (flag injection) - branch_policy.rs: action validation (block|warn only), path validation Rust fixes (MEDIUM): - export.rs: named column access (positional→named) - budget.rs: named column access, negative value guards Svelte fixes: - AccountSwitcher: 2-step confirmation before account switch - ProjectMemory: expand/collapse content, 2-step delete confirm, tags split fix - CodeIntelligence: min 2-char symbol query, CodeSymbol rename, aria-labels - BudgetManager: 10M upper bound, aria-label on input, named constants - SessionExporter: timeout cleanup on destroy, aria-live feedback - AnalyticsDashboard: SVG aria-label, removed unused import, named constant
This commit is contained in:
parent
0324f813e2
commit
738574b9f0
13 changed files with 280 additions and 91 deletions
|
|
@ -10,6 +10,7 @@
|
|||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let switching = $state<string | null>(null);
|
||||
let confirmSwitch = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
|
|
@ -75,14 +76,31 @@
|
|||
</div>
|
||||
<div class="account-action">
|
||||
{#if account.isActive}
|
||||
<span class="active-label">Active</span>
|
||||
<span class="active-label" aria-label="Currently active account">Active</span>
|
||||
{:else if confirmSwitch === account.id}
|
||||
<button
|
||||
class="switch-btn confirm"
|
||||
disabled={switching !== null}
|
||||
aria-label="Confirm switching to {account.displayName}"
|
||||
onclick={() => { confirmSwitch = null; switchTo(account.id); }}
|
||||
>
|
||||
{switching === account.id ? 'Switching...' : 'Confirm?'}
|
||||
</button>
|
||||
<button
|
||||
class="switch-btn"
|
||||
aria-label="Cancel account switch"
|
||||
onclick={() => (confirmSwitch = null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="switch-btn"
|
||||
disabled={switching !== null}
|
||||
onclick={() => switchTo(account.id)}
|
||||
aria-label="Switch to {account.displayName}"
|
||||
onclick={() => (confirmSwitch = account.id)}
|
||||
>
|
||||
{switching === account.id ? 'Switching...' : 'Switch'}
|
||||
Switch
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -199,6 +217,9 @@
|
|||
|
||||
.account-action {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.active-label {
|
||||
|
|
@ -226,4 +247,14 @@
|
|||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-btn.confirm {
|
||||
color: var(--ctp-peach);
|
||||
border-color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
.switch-btn.confirm:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
proAnalyticsSummary,
|
||||
proAnalyticsDaily,
|
||||
|
|
@ -26,7 +25,9 @@
|
|||
let daily = $state<DailyStats[]>([]);
|
||||
let models = $state<ModelBreakdown[]>([]);
|
||||
|
||||
let maxDailyCost = $derived(Math.max(...daily.map((d) => d.costUsd), 0.001));
|
||||
/** Floor value to prevent division-by-zero when all daily costs are 0 */
|
||||
const MIN_CHART_SCALE = 0.001;
|
||||
let maxDailyCost = $derived(Math.max(...daily.map((d) => d.costUsd), MIN_CHART_SCALE));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
|
|
@ -101,7 +102,7 @@
|
|||
{#if daily.length > 0}
|
||||
<div class="section-title">Daily Cost</div>
|
||||
<div class="chart-container">
|
||||
<svg viewBox="0 0 {daily.length * 20} 100" class="bar-chart" preserveAspectRatio="none">
|
||||
<svg viewBox="0 0 {daily.length * 20} 100" class="bar-chart" preserveAspectRatio="none" role="img" aria-label="Daily cost bar chart">
|
||||
{#each daily as d, i}
|
||||
{@const h = Math.max((d.costUsd / maxDailyCost) * 90, 1)}
|
||||
<rect
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@
|
|||
let routerProfile = $state<string>('balanced');
|
||||
let recommendation = $state<ModelRecommendation | null>(null);
|
||||
|
||||
const MAX_TOKEN_LIMIT = 10_000_000;
|
||||
const DEFAULT_RECOMMEND_ROLE = 'default';
|
||||
const DEFAULT_RECOMMEND_PROMPT_LENGTH = 4000;
|
||||
const DEFAULT_RECOMMEND_PROVIDER = 'claude';
|
||||
|
||||
const PROFILES = [
|
||||
{ id: 'cost_saver', label: 'Cost Saver', desc: 'Smaller models, lower cost', icon: '$' },
|
||||
{ id: 'balanced', label: 'Balanced', desc: 'Smart routing by task', icon: '~' },
|
||||
|
|
@ -49,7 +54,7 @@
|
|||
budget = b;
|
||||
routerProfile = profile;
|
||||
limitInput = String(b.limit);
|
||||
recommendation = await proRouterRecommend(projectId, 'default', 4000, 'claude');
|
||||
recommendation = await proRouterRecommend(projectId, DEFAULT_RECOMMEND_ROLE, DEFAULT_RECOMMEND_PROMPT_LENGTH, DEFAULT_RECOMMEND_PROVIDER);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
|
|
@ -64,7 +69,7 @@
|
|||
|
||||
async function setLimit() {
|
||||
const val = parseInt(limitInput, 10);
|
||||
if (isNaN(val) || val <= 0) return;
|
||||
if (isNaN(val) || val <= 0 || val > MAX_TOKEN_LIMIT) return;
|
||||
settingLimit = true;
|
||||
try {
|
||||
await proBudgetSet(projectId, val);
|
||||
|
|
@ -80,7 +85,7 @@
|
|||
try {
|
||||
await proRouterSetProfile(projectId, id);
|
||||
routerProfile = id;
|
||||
recommendation = await proRouterRecommend(projectId, 'default', 4000, 'claude');
|
||||
recommendation = await proRouterRecommend(projectId, DEFAULT_RECOMMEND_ROLE, DEFAULT_RECOMMEND_PROMPT_LENGTH, DEFAULT_RECOMMEND_PROVIDER);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
|
@ -122,6 +127,8 @@
|
|||
type="number"
|
||||
bind:value={limitInput}
|
||||
placeholder="Monthly token limit"
|
||||
aria-label="Monthly token limit"
|
||||
max={MAX_TOKEN_LIMIT}
|
||||
/>
|
||||
<button class="btn" onclick={setLimit} disabled={settingLimit}>
|
||||
{settingLimit ? 'Setting...' : 'Set Limit'}
|
||||
|
|
|
|||
|
|
@ -8,15 +8,14 @@
|
|||
proSymbolsSearch,
|
||||
type GitContext,
|
||||
type PolicyDecision,
|
||||
type Symbol,
|
||||
type CodeSymbol,
|
||||
} from './pro-bridge';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
let { projectId, projectPath }: Props = $props();
|
||||
let { projectPath }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
|
@ -28,7 +27,7 @@
|
|||
let scanResult = $state<{ filesScanned: number; symbolsFound: number; durationMs: number } | null>(null);
|
||||
let scanning = $state(false);
|
||||
let symbolQuery = $state('');
|
||||
let symbols = $state<Symbol[]>([]);
|
||||
let symbols = $state<CodeSymbol[]>([]);
|
||||
let searchingSymbols = $state(false);
|
||||
|
||||
const KIND_COLORS: Record<string, string> = {
|
||||
|
|
@ -65,7 +64,7 @@
|
|||
}
|
||||
|
||||
async function searchSymbols() {
|
||||
if (!symbolQuery.trim()) { symbols = []; return; }
|
||||
if (!symbolQuery.trim() || symbolQuery.trim().length < 2) { symbols = []; return; }
|
||||
searchingSymbols = true;
|
||||
try { symbols = await proSymbolsSearch(projectPath, symbolQuery.trim()); }
|
||||
catch (e) { error = errMsg(e); }
|
||||
|
|
@ -148,8 +147,8 @@
|
|||
</div>
|
||||
|
||||
<div class="search-row">
|
||||
<input class="search-input" type="text" bind:value={symbolQuery} placeholder="Search symbols..." onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && searchSymbols()} />
|
||||
<button class="btn" onclick={searchSymbols} disabled={searchingSymbols}>{searchingSymbols ? '...' : 'Find'}</button>
|
||||
<input class="search-input" type="text" bind:value={symbolQuery} placeholder="Search symbols..." aria-label="Symbol search query" onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && searchSymbols()} />
|
||||
<button class="btn" onclick={searchSymbols} disabled={searchingSymbols} aria-label="Find symbols">{searchingSymbols ? '...' : 'Find'}</button>
|
||||
</div>
|
||||
{#if symbols.length > 0}
|
||||
<div class="symbol-list">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@
|
|||
let addSource = $state<'human' | 'agent' | 'auto'>('human');
|
||||
let adding = $state(false);
|
||||
|
||||
// Expand/collapse
|
||||
let expandedIds = $state<Set<number>>(new Set());
|
||||
function toggleExpand(id: number) {
|
||||
const next = new Set(expandedIds);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
expandedIds = next;
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
let confirmDeleteId = $state<number | null>(null);
|
||||
|
||||
// Inject
|
||||
let injectedPreview = $state<string | null>(null);
|
||||
let injecting = $state(false);
|
||||
|
|
@ -106,7 +117,7 @@
|
|||
<button class="btn secondary" onclick={injectContext} disabled={injecting}>
|
||||
{injecting ? 'Injecting...' : 'Inject Context'}
|
||||
</button>
|
||||
<button class="btn secondary" disabled>Extract from Session</button>
|
||||
<button class="btn secondary" disabled title="Coming soon">Extract from Session</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -152,17 +163,35 @@
|
|||
<div class="card-header">
|
||||
<span class="trust-badge" style:background={TRUST_COLORS[mem.trust] ?? 'var(--ctp-overlay0)'}>{mem.source}</span>
|
||||
<span class="card-date">{fmtDate(mem.createdAt)}</span>
|
||||
<button class="del-btn" onclick={() => removeMemory(mem.id)}>x</button>
|
||||
{#if confirmDeleteId === mem.id}
|
||||
<button class="del-btn confirm" onclick={() => { confirmDeleteId = null; removeMemory(mem.id); }}>Confirm?</button>
|
||||
<button class="del-btn" onclick={() => (confirmDeleteId = null)}>Cancel</button>
|
||||
{:else}
|
||||
<button class="del-btn" onclick={() => (confirmDeleteId = mem.id)}>x</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-content">{mem.content.length > 200 ? mem.content.slice(0, 200) + '...' : mem.content}</div>
|
||||
<div class="card-content">
|
||||
{#if expandedIds.has(mem.id)}
|
||||
{mem.content}
|
||||
{:else if mem.content.length > 200}
|
||||
{mem.content.slice(0, 200)}...
|
||||
{:else}
|
||||
{mem.content}
|
||||
{/if}
|
||||
</div>
|
||||
{#if mem.content.length > 200}
|
||||
<button class="expand-btn" onclick={() => toggleExpand(mem.id)}>
|
||||
{expandedIds.has(mem.id) ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="card-footer">
|
||||
<div class="confidence-bar-bg">
|
||||
<div class="confidence-bar-fill" style:width="{mem.confidence * 100}%"></div>
|
||||
</div>
|
||||
{#if mem.tags}
|
||||
<div class="card-tags">
|
||||
{#each mem.tags.split(',') as tag}
|
||||
<span class="tag">{tag.trim()}</span>
|
||||
{#each mem.tags.split(',').filter(Boolean).map(t => t.trim()) as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -234,6 +263,9 @@
|
|||
.card-date { font-size: 0.6875rem; color: var(--ctp-overlay0); margin-left: auto; }
|
||||
.del-btn { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.75rem; padding: 0 0.25rem; }
|
||||
.del-btn:hover { color: var(--ctp-red); }
|
||||
.del-btn.confirm { color: var(--ctp-red); font-weight: 600; }
|
||||
.expand-btn { background: none; border: none; color: var(--ctp-blue); cursor: pointer; font-size: 0.6875rem; padding: 0; align-self: flex-start; }
|
||||
.expand-btn:hover { text-decoration: underline; }
|
||||
.card-content { font-size: 0.75rem; color: var(--ctp-subtext1); line-height: 1.4; }
|
||||
.card-footer { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.confidence-bar-bg { height: 0.1875rem; background: var(--ctp-surface1); border-radius: 0.125rem; overflow: hidden; }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
proExportSession,
|
||||
proExportProjectSummary,
|
||||
|
|
@ -24,6 +25,11 @@
|
|||
let error = $state<string | null>(null);
|
||||
let markdown = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(copiedTimer);
|
||||
});
|
||||
|
||||
async function generate() {
|
||||
loading = true;
|
||||
|
|
@ -49,7 +55,8 @@
|
|||
try {
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
clearTimeout(copiedTimer);
|
||||
copiedTimer = setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
error = 'Failed to copy to clipboard';
|
||||
}
|
||||
|
|
@ -88,7 +95,7 @@
|
|||
{loading ? 'Generating...' : 'Generate Report'}
|
||||
</button>
|
||||
{#if markdown}
|
||||
<button class="btn-secondary" onclick={copyToClipboard}>
|
||||
<button class="btn-secondary" onclick={copyToClipboard} aria-live="polite">
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue