'use strict'; const vscode = require('vscode'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { spawn } = require('child_process'); const MAX_HOOK_OUTPUT = 1024 * 1024; function projectRoot() { const folders = vscode.workspace.workspaceFolders || []; for (const folder of folders) { const root = folder.uri.fsPath; if (fs.existsSync(path.join(root, '.casan', 'config.json'))) { return root; } } return undefined; } function pythonCommand() { const configured = vscode.workspace.getConfiguration('casan').get('pythonPath', '').trim(); if (configured) { return { command: configured, prefix: [] }; } return process.platform === 'win32' ? { command: 'py', prefix: ['-3'] } : { command: 'python3', prefix: [] }; } function runHook(root, event, payload, token) { const bootstrap = path.join(root, '.casan', 'casan-hook.py'); if (!fs.existsSync(bootstrap)) { return Promise.reject(new Error('Missing .casan/casan-hook.py. Run `casan init` again.')); } const runtime = pythonCommand(); const timeoutMs = vscode.workspace.getConfiguration('casan').get('hookTimeoutMs', 20000); const args = [...runtime.prefix, bootstrap, '--client', 'vscode-copilot', '--event', event]; return new Promise((resolve, reject) => { const child = spawn(runtime.command, args, { cwd: root, env: { ...process.env, CASAN_APP_ROOT: root }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; let settled = false; const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); cancellation.dispose(); if (error) reject(error); else resolve(value); }; const timer = setTimeout(() => { child.kill(); finish(new Error(`CASAN ${event} timed out after ${timeoutMs} ms`)); }, timeoutMs); const cancellation = token.onCancellationRequested(() => { child.kill(); finish(new vscode.CancellationError()); }); child.stdout.on('data', chunk => { stdout += chunk.toString('utf8'); if (stdout.length > MAX_HOOK_OUTPUT) { child.kill(); finish(new Error('CASAN hook output exceeded the safety limit')); } }); child.stderr.on('data', chunk => { stderr += chunk.toString('utf8'); if (stderr.length > MAX_HOOK_OUTPUT) stderr = stderr.slice(-MAX_HOOK_OUTPUT); }); child.on('error', error => finish(error)); child.on('close', code => { let parsed; try { parsed = JSON.parse(stdout.trim() || '{}'); } catch (_error) { finish(new Error(`CASAN ${event} returned invalid JSON${stderr ? `: ${stderr.trim()}` : ''}`)); return; } if (code !== 0 && parsed.decision !== 'block') { finish(new Error(parsed.reason || stderr.trim() || `CASAN ${event} failed (${code})`)); return; } finish(undefined, parsed); }); child.stdin.end(JSON.stringify(payload)); }); } async function abortQuietly(root, admissionId, reason) { if (!admissionId) return; const source = new vscode.CancellationTokenSource(); try { await runHook(root, 'Abort', { admission_id: admissionId, reason }, source.token); } catch (_error) { // The primary error is more useful to the user; abort remains best-effort. } finally { source.dispose(); } } async function handler(request, _context, stream, token) { const root = projectRoot(); if (!root) { stream.markdown('CASAN is not initialized in this workspace. Run `casan init` at the project root.'); return { metadata: { certified: false, reason: 'not_initialized' } }; } if (!vscode.workspace.isTrusted) { stream.markdown('CASAN requires a trusted VS Code workspace before it can run project governance.'); return { metadata: { certified: false, reason: 'workspace_untrusted' } }; } if (request.command === 'status') { stream.markdown('CASAN is initialized. Prompts sent explicitly to `@casan` use the governed route. Built-in Copilot chat is not globally intercepted.'); return { metadata: { certified: false, reason: 'status_only' } }; } const sessionId = crypto.randomUUID(); const turnId = crypto.randomUUID(); const started = Date.now(); let admissionId; try { const begin = await runHook(root, 'Begin', { project: root, session_id: sessionId, turn_id: turnId, prompt: request.prompt, client_version: vscode.version }, token); if (begin.decision === 'block' || !begin.admission_id) { stream.markdown(`CASAN blocked this prompt: ${begin.reason || 'admission denied'}`); return { metadata: { certified: false, traceId: begin.trace_id } }; } admissionId = begin.admission_id; stream.progress(`CASAN admission ${begin.trace_id || 'opened'}`); const messages = [ vscode.LanguageModelChatMessage.User( 'You are operating through the CASAN governed chat route. Follow the user request, do not claim to have executed tools or changed files, and clearly state when a requested side effect requires a supported agentic client.' ), vscode.LanguageModelChatMessage.User(request.prompt) ]; const response = await request.model.sendRequest(messages, {}, token); let summary = ''; for await (const fragment of response.text) { summary += fragment; stream.markdown(fragment); } await runHook(root, 'Telemetry', { admission_id: admissionId, model: request.model.id, runtime_ms: Date.now() - started, cost_source: 'unavailable' }, token); const finalized = await runHook(root, 'Finalize', { admission_id: admissionId, stop_reason: 'completed', assistant_summary: summary.slice(0, 4000) }, token); return { metadata: { certified: finalized.decision === 'certified', traceId: finalized.trace_id, certificationStrength: finalized.certification_strength } }; } catch (error) { await abortQuietly(root, admissionId, token.isCancellationRequested ? 'vscode_cancelled' : `vscode_error:${error instanceof Error ? error.message : String(error)}`); if (error instanceof vscode.CancellationError) throw error; stream.markdown(`CASAN governed chat failed safely: ${error instanceof Error ? error.message : String(error)}`); return { metadata: { certified: false, reason: 'runtime_error' } }; } } function activate(context) { const participant = vscode.chat.createChatParticipant('fpt-casan.casan', handler); context.subscriptions.push(participant); } function deactivate() {} module.exports = { activate, deactivate };