fix: address PR review — CSP nonce, dead branch, restart cooldown

1. Webview CSP nonce (security): Added Content-Security-Policy meta tag
   with nonce-based script-src to sidebar.ts. Replaced all inline
   onclick handlers with data-command attributes and a single delegated
   event listener, which CSP requires over inline handlers.

2. Dead branch in chat-participant.ts: Removed the isSlashCommand
   conditional that ran identical code for both paths — slash commands
   and regular messages both call sendPrompt() the same way.

3. Restart loop cooldown in gsd-client.ts: Added a 60-second sliding
   window that tracks crash timestamps. If the process crashes more
   than 3 times within 60 seconds, auto-restart is disabled and an
   error is surfaced to the user via the onError event emitter.
This commit is contained in:
Jeremy McSpadden 2026-03-16 17:28:32 -05:00
parent 6ed9cd5359
commit add9e8cf3c
3 changed files with 46 additions and 26 deletions

View file

@ -26,9 +26,6 @@ export function registerChatParticipant(
return;
}
// If the message starts with /, forward as a slash command prompt
const isSlashCommand = message.startsWith("/");
// Track streaming events while the prompt executes
let agentDone = false;
let totalInputTokens = 0;
@ -127,12 +124,7 @@ export function registerChatParticipant(
});
try {
if (isSlashCommand) {
// Forward slash commands as regular prompts
await client.sendPrompt(message);
} else {
await client.sendPrompt(message);
}
await client.sendPrompt(message);
// Wait for agent_end or cancellation
await new Promise<void>((resolve) => {

View file

@ -86,6 +86,7 @@ export class GsdClient implements vscode.Disposable {
private requestId = 0;
private buffer = "";
private restartCount = 0;
private restartTimestamps: number[] = [];
private readonly _onEvent = new vscode.EventEmitter<AgentEvent>();
readonly onEvent = this._onEvent.event;
@ -142,9 +143,21 @@ export class GsdClient implements vscode.Disposable {
this.rejectAllPending(`GSD process exited (code=${code}, signal=${signal})`);
this._onConnectionChange.fire(false);
if (this.restartCount < 3 && code !== 0 && signal !== "SIGTERM") {
this.restartCount++;
setTimeout(() => this.start(), 1000 * this.restartCount);
if (code !== 0 && signal !== "SIGTERM") {
const now = Date.now();
this.restartTimestamps.push(now);
// Keep only timestamps within the last 60 seconds
this.restartTimestamps = this.restartTimestamps.filter(t => now - t < 60_000);
if (this.restartTimestamps.length > 3) {
// Too many crashes within 60s — stop retrying
this._onError.fire(
`GSD process crashed ${this.restartTimestamps.length} times within 60s. Not restarting. Use "GSD: Start Agent" to retry manually.`,
);
} else if (this.restartCount < 3) {
this.restartCount++;
setTimeout(() => this.start(), 1000 * this.restartCount);
}
}
});

View file

@ -199,11 +199,14 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
? `<div class="streaming-indicator"><span class="spinner"></span> Agent is working...</div>`
: "";
const nonce = getNonce();
return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<style>
body {
font-family: var(--vscode-font-family);
@ -380,16 +383,16 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
<div class="section-title">Controls</div>
<div class="btn-group">
${info.connected
? `<button onclick="send('stop')">Stop Agent</button>
? `<button data-command="stop">Stop Agent</button>
<div class="btn-row">
<button class="secondary" onclick="send('newSession')">New Session</button>
<button class="secondary" onclick="send('switchModel')">Model</button>
<button class="secondary" data-command="newSession">New Session</button>
<button class="secondary" data-command="switchModel">Model</button>
</div>
<div class="btn-row">
<button class="secondary" onclick="send('cycleThinking')">Thinking</button>
<button class="secondary" onclick="send('toggleAutoCompaction')">Auto-Compact</button>
<button class="secondary" data-command="cycleThinking">Thinking</button>
<button class="secondary" data-command="toggleAutoCompaction">Auto-Compact</button>
</div>`
: `<button onclick="send('start')">Start Agent</button>`
: `<button data-command="start">Start Agent</button>`
}
</div>
</div>
@ -399,22 +402,25 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
<div class="section-title">Actions</div>
<div class="btn-group">
<div class="btn-row">
<button class="secondary" onclick="send('compact')">Compact</button>
<button class="secondary" onclick="send('exportHtml')">Export</button>
<button class="secondary" data-command="compact">Compact</button>
<button class="secondary" data-command="exportHtml">Export</button>
</div>
<div class="btn-row">
<button class="secondary" onclick="send('abort')">Abort</button>
<button class="secondary" onclick="send('listCommands')">Commands</button>
<button class="secondary" data-command="abort">Abort</button>
<button class="secondary" data-command="listCommands">Commands</button>
</div>
</div>
</div>
` : ""}
<script>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
function send(command, value) {
vscode.postMessage({ command, value });
}
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-command]');
if (btn) {
vscode.postMessage({ command: btn.dataset.command });
}
});
</script>
</body>
</html>`;
@ -428,3 +434,12 @@ function escapeHtml(text: string): string {
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function getNonce(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let nonce = "";
for (let i = 0; i < 32; i++) {
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
}
return nonce;
}