diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 86f6c579..9cc013f5 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1339,6 +1339,12 @@ "description": "Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications.", "version": "1.0.0" }, + { + "name": "sentry-triage", + "source": "plugins/sentry-triage", + "description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.", + "version": "1.0.0" + }, { "name": "signals-dashboard", "source": "plugins/signals-dashboard", diff --git a/docs/README.plugins.md b/docs/README.plugins.md index 81fb07a0..f5f0d486 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -104,6 +104,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [rust-mcp-development](../plugins/rust-mcp-development/README.md) | Build high-performance Model Context Protocol servers in Rust using the official rmcp SDK with async/await, procedural macros, and type-safe implementations. | 0 items | rust, mcp, model-context-protocol, server-development, sdk, tokio, async, macros, rmcp | | [salesforce-development](../plugins/salesforce-development/README.md) | Complete Salesforce agentic development environment covering Apex & Triggers, Flow automation, Lightning Web Components, Aura components, and Visualforce pages. | 0 items | salesforce, apex, triggers, lwc, aura, flow, visualforce, crm, salesforce-dx | | [security-best-practices](../plugins/security-best-practices/README.md) | Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications. | 0 items | security, accessibility, performance, code-quality, owasp, a11y, optimization, best-practices | +| [sentry-triage](../plugins/sentry-triage/README.md) | Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR. | 1 items | canvas, copilot-canvas, copilot-extension, error-triage, incident-response, on-call, sentry | | [signals-dashboard](../plugins/signals-dashboard/README.md) | Real-time Workshop dashboard with agent signals, honesty calibration, cost-aware desk profiles, and fail-closed Local Delegation. | 1 items | agent-signals, dashboard, multi-agent, coordination, canvas, local-delegation | | [site-studio](../plugins/site-studio/README.md) | Plan, draft, and track a personal website section by section β€” a shared canvas where you and your agent author content, watch progress, and review every change. | 1 items | agent-collaboration, content-authoring, personal-website, progress-tracking, section-planning, site-builder | | [skill-image-gen](../plugins/skill-image-gen/README.md) | Generate images using AI directly from your coding workflow. Supports OpenAI (gpt-image-2) and Google Gemini. BYO API key β€” the skill guides you through setup on first use. | 0 items | image-generation, openai, gemini, ai, art, sprites, textures, icons | diff --git a/extensions/sentry-triage/LICENSE b/extensions/sentry-triage/LICENSE new file mode 100644 index 00000000..da965079 --- /dev/null +++ b/extensions/sentry-triage/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Liz Tom + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/sentry-triage/README.md b/extensions/sentry-triage/README.md new file mode 100644 index 00000000..85a3cbcb --- /dev/null +++ b/extensions/sentry-triage/README.md @@ -0,0 +1,111 @@ +# Sentry Triage Canvas + +A GitHub Copilot canvas for on-call error triage. It scans your live Sentry +issues, groups them by why they need attention, and lets you hand a selected +issue off to Copilot β€” either to file a tracking issue or to spin up a session +that drafts a fix pull request. + +## Features + +- **Live scan** of Sentry issues for an organization (optionally narrowed to a + single project), grouped by urgency. +- **Plain-English titles** toggle that rewrites the raw Sentry error into a + one-sentence, user-facing summary. +- **Tracking detection** so already-triaged issues (filed with the + `sentry-triage` label) are recognized on later scans. +- **πŸ“ Create issue** files or reuses a tracking issue only β€” no code, branch, or + PR. +- **πŸ”§ Fix with Copilot** files/reuses the tracking issue **and** spawns a + dedicated session that drafts a fix PR. +- **Settings panel** to confirm the repository, local checkout, base branch, and + issue tracker before anything is created. + +## Files + +- `extension.mjs` β€” canvas declaration, agent hand-off tools, and orchestration. +- `server.mjs` β€” loopback HTTP server that backs the canvas webview. +- `state.mjs` β€” per-canvas state and render coordination. +- `styles.mjs` β€” canvas styling. +- `sentry.mjs` / `sentryClient.mjs` β€” Sentry CLI (library mode) access: scan + issues, list orgs and projects. +- `preflight.mjs` β€” Sentry sign-in / connection checks with the setup gate. +- `prefs.mjs` β€” persisted user preferences. +- `escape.mjs` β€” prompt/HTML escaping helpers. +- `components/` β€” `page.mjs`, `card.mjs`, and `category.mjs` webview components. +- `assets/preview.png` β€” preview image for the extensions gallery. +- `package.json` β€” ESM entry point and runtime dependencies. +- `copilot-extension.json` β€” Copilot extension name/version metadata. + +## Prerequisites + +- **Node.js 22 or newer.** +- The GitHub Copilot app canvas / UI-extensions experiment enabled. +- A Sentry sign-in. This canvas reads issues through the + [Sentry CLI](https://cli.sentry.dev) in library mode β€” there is no MCP server + to configure. Sign in once from your terminal: + + ```sh + sentry auth login + ``` + + This stores an OAuth credential the canvas auto-detects. For non-interactive + environments, export a token instead: + + ```sh + export SENTRY_AUTH_TOKEN= + ``` + + The login or token needs these scopes: `event:read`, `org:read`, + `project:read`. The canvas checks for a valid sign-in each time it opens; if + you aren't signed in it shows a setup gate with the exact reason. + +## Install + +Drop this folder at `~/.copilot/extensions/sentry-triage/` for user scope, or in +a repository at `.github/extensions/sentry-triage/` for project scope. Then +install dependencies from inside the copied folder (this canvas depends on the +`sentry` npm package at runtime): + +```sh +# User scope +cd ~/.copilot/extensions/sentry-triage + +# Or project scope, from the repository root +cd .github/extensions/sentry-triage + +npm install +``` + +Reload extensions in the GitHub Copilot app, then open the `sentry-triage` +canvas. + +## Open the canvas in the correct repository scope + +Open the canvas from a Copilot session scoped to the repository where you want +issues and draft pull requests created, so it can detect the repository, local +checkout, and base branch automatically. If you opened it outside the intended +repository, open **Settings** and confirm the targets before selecting **Create +issue** or **Fix with Copilot**. + +## Use the canvas + +1. Open the canvas from the repository you want to work in. +2. Confirm the Sentry **organization** (auto-detected from your sign-in). If your + account has more than one org, pick it from the dropdown. **Scan issues** + stays disabled until an organization is set. +3. Optionally narrow to a single **project** using the autocomplete field. Leave + it blank to scan the whole org. +4. Review the issues grouped by why they need attention. Toggle **Plain-English + titles** to swap the raw Sentry error for a readable summary. +5. Select issues and choose an action in the toolbar β€” **πŸ“ Create issue** or + **πŸ”§ Fix with Copilot**. + +## Agent tools + +The canvas exposes structured hand-off tools the agent calls instead of printing +JSON into the timeline: `submit_issue_summaries`, `submit_projects`, +`submit_tracking`, `submit_related`, and `submit_work_pr`. + +## License + +MIT β€” see [LICENSE](LICENSE). diff --git a/extensions/sentry-triage/assets/icon.png b/extensions/sentry-triage/assets/icon.png new file mode 100644 index 00000000..bc589c6f Binary files /dev/null and b/extensions/sentry-triage/assets/icon.png differ diff --git a/extensions/sentry-triage/assets/preview.png b/extensions/sentry-triage/assets/preview.png new file mode 100644 index 00000000..8fd5aadd Binary files /dev/null and b/extensions/sentry-triage/assets/preview.png differ diff --git a/extensions/sentry-triage/components/card.mjs b/extensions/sentry-triage/components/card.mjs new file mode 100644 index 00000000..4ad20d12 --- /dev/null +++ b/extensions/sentry-triage/components/card.mjs @@ -0,0 +1,115 @@ +import { escapeHtml, safeHref } from '../escape.mjs' + +function statusLabel(workStatus) { + if (!workStatus || typeof workStatus !== 'object') return '' + if (workStatus.phase === 'working' || workStatus.phase === 'queued') return '⏳ working…' + if (workStatus.phase === 'done') { + // Numbers move into clickable links (statusLinks); keep the label a plain badge. + return 'created βœ“' + } + if (workStatus.phase === 'handed-off') { + const sessionPart = workStatus.sessionName ? `🧡 ${workStatus.sessionName}` : '🧡 session started' + return `${sessionPart} β†—` + } + if (workStatus.phase === 'skipped') return 'πŸ”’ already being worked on' + if (workStatus.phase === 'tracked') return 'πŸ‘€ Tracked' + if (workStatus.phase === 'error') return `⚠️ ${workStatus.error || 'work failed'}` + return '' +} + +function stateSuffix(stateStr) { + const s = typeof stateStr === 'string' ? stateStr.trim().toLowerCase() : '' + return s ? ` (${s})` : '' +} + +function linkOrText(url, label) { + return url + ? `${escapeHtml(label)}` + : `${escapeHtml(label)}` +} + +function statusLinks(workStatus) { + if (!workStatus || typeof workStatus !== 'object') return '' + const links = [] + const phase = workStatus.phase + // Freshly created artifacts (done / handed-off): link the new issue and PR the + // same way tracked/skipped link their existing ones, so the numbers are clickable. + if (phase === 'done' || phase === 'handed-off') { + if (workStatus.issueUrl || workStatus.issueNumber) { + const base = workStatus.issueNumber ? `issue #${workStatus.issueNumber}` : 'issue created' + links.push(linkOrText(workStatus.issueUrl, base)) + } + if ((phase === 'done' || phase === 'handed-off') && (workStatus.prUrl || workStatus.prNumber)) { + const base = workStatus.prNumber ? `PR #${workStatus.prNumber}` : 'PR opened' + const label = `${base}${stateSuffix(workStatus.prState)}` + links.push(linkOrText(workStatus.prUrl, label)) + } + } + if (workStatus.existingIssueUrl) { + const base = workStatus.existingIssueNumber ? `issue #${workStatus.existingIssueNumber}` : 'existing issue' + const label = `${base}${stateSuffix(workStatus.existingIssueState)}` + links.push(linkOrText(workStatus.existingIssueUrl, label)) + } + if (workStatus.existingPrUrl) { + const base = workStatus.existingPrNumber ? `PR #${workStatus.existingPrNumber}` : 'existing PR' + const label = `${base}${stateSuffix(workStatus.existingPrState)}` + links.push(linkOrText(workStatus.existingPrUrl, label)) + } + return links.join(' Β· ') +} + +export function Card({ key, summary, plainEnglish, reason, events, users, url, workStatus, plainEnglishView, availableModels = [] }) { + const href = safeHref(url) + const safeKey = escapeHtml(key) + const title = plainEnglishView ? (plainEnglish || summary) : summary + const status = statusLabel(workStatus) + const links = statusLinks(workStatus) + const statusClass = workStatus?.phase === 'done' + ? 'done' + : workStatus?.phase === 'handed-off' + ? 'handed-off' + : workStatus?.phase === 'skipped' + ? 'skipped' + : workStatus?.phase === 'tracked' + ? 'tracked' + : workStatus?.phase === 'error' + ? 'error' + : workStatus?.phase === 'working' || workStatus?.phase === 'queued' + ? 'working' + : 'idle' + const meta = [ + events != null ? `${Number(events).toLocaleString()} events` : null, + users != null ? `${Number(users).toLocaleString()} users` : null + ].filter(Boolean).join(' Β· ') + + // Per-card model override. Empty value means "use the toolbar/batch default". + const cardModelOptions = [''] + .concat( + (Array.isArray(availableModels) ? availableModels : []) + .filter((model) => model && model.id) + .map((model) => ``) + ) + .join('') + + return `
+ + + +
` +} diff --git a/extensions/sentry-triage/components/category.mjs b/extensions/sentry-triage/components/category.mjs new file mode 100644 index 00000000..50b1a2e0 --- /dev/null +++ b/extensions/sentry-triage/components/category.mjs @@ -0,0 +1,29 @@ +import { Card } from './card.mjs' +import { escapeHtml } from '../escape.mjs' + +const CATEGORY_DESCRIPTIONS = { + 'regressions': 'Issues that previously existed, were resolved, and have resurfaced β€” likely tied to a recent release.', + 'escalating': 'Issues Sentry flagged as escalating, plus older high-impact issues affecting many users or generating many events.', + 'new-critical': 'Brand new issues that have already hit multiple users within hours of first appearing.' +} + +export function Category({ id, name, issues, plainEnglishView, availableModels = [] }) { + if (!issues.length) return '' + + const description = CATEGORY_DESCRIPTIONS[id] || '' + const safeId = escapeHtml(id) + + return `
+
+

${escapeHtml(name)} ${issues.length}

+ + ${description ? `

${description}

` : ''} +
+
+ ${issues.map((issue) => Card({ ...issue, plainEnglishView, availableModels })).join('')} +
+
` +} diff --git a/extensions/sentry-triage/components/page.mjs b/extensions/sentry-triage/components/page.mjs new file mode 100644 index 00000000..e390ace1 --- /dev/null +++ b/extensions/sentry-triage/components/page.mjs @@ -0,0 +1,2035 @@ +import { styles } from '../styles.mjs' +import { Category } from './category.mjs' +import { escapeHtml, jsonForScript } from '../escape.mjs' + +// Subtitle + empty-state copy shared by the server render below. The inline +// client script keeps byte-identical wording (search "KEEP IN SYNC") so the +// first paint and every SSE re-render read the same. `total` is how many open +// issues the scan examined; `capped` means the scan hit its configured cap +// so we render "N+" to signal there may be more. +function scannedLabel(total, capped) { + return capped ? String(total) + '+' : String(total) +} +function subtitleText(shown, total, capped) { + if (total > shown) { + return 'Top ' + shown + ' of ' + scannedLabel(total, capped) + ' open issues, prioritized by impact' + } + return shown + ' open issue' + (shown === 1 ? '' : 's') + ' to triage' +} +function emptyText(total, capped, project, older = 0, olderCapped = false) { + const where = project ? ' for ' + project : '' + if (total > 0) { + return 'βœ“ Scanned ' + scannedLabel(total, capped) + ' open issue' + (total === 1 ? '' : 's') + where + + ' β€” none are urgent enough to surface right now.' + } + if (older > 0) { + const one = older === 1 && !olderCapped + return 'βœ“ No open issues' + where + ' in this window β€” Sentry has ' + scannedLabel(older, olderCapped) + + ' unresolved issue' + (one ? '' : 's') + ' in the last 90 days.' + } + return 'βœ“ No open issues' + where + ' in this window.' +} + +export function Page({ + categories, + scanError = '', + scannedTotal = 0, + scannedCapped = false, + scannedOlder = 0, + scannedOlderCapped = false, + org, + orgOptions = [], + orgDefault = '', + savedDefaultOrg = '', + project, + period = '24h', + periods = [], + connections, + prTargets, + prSettingsOpen, + plainEnglishView = false, + projectOptions = [], + projects = [], + availableModels = [], + issueTrackers, + selectedTracker, + workByIssueKey, + csrfToken = '', +}) { + const conn = connections && typeof connections === 'object' + ? connections + : { checked: false, sentry: { reachable: false, error: '' } } + const checked = Boolean(conn.checked) + // Optimistic before the first preflight so we never flash a gate spuriously. + const sentryReady = !checked || Boolean(conn.sentry && conn.sentry.reachable) + // The humanized preflight failure (unsupported host, dropped connection, sign-in + // required, …). Shown in the gate so users see the real reason instead of a + // generic "connect" message for problems reconnecting won't fix. + const gateError = (conn.sentry && conn.sentry.error) || '' + // Distinguish "not signed in" (needs `sentry auth login`) from a transient + // connectivity failure where a credential likely exists. probeSentry() reports + // configured:false only for auth failures, so a gated-but-configured state means + // Sentry was simply unreachable β€” the gate then shows connectivity guidance + // instead of wrongly telling an already-authenticated user to re-run login. + const signedOut = !(conn.sentry && conn.sentry.configured) + // Among configured-but-unreachable failures, only a transient network blip is + // worth promising recovery for. An unknown, settled failure (classifySentryError + // marks transient:false) gets neutral guidance instead of a false "it should + // recover on the next check" β€” the humanized error carries the real reason. + const transientConn = Boolean(conn.sentry && conn.sentry.transient) + + const categoriesWithStatus = categories.map((category) => ({ + ...category, + issues: category.issues.map((issue) => ({ + ...issue, + workStatus: workByIssueKey[issue.key], + })), + })) + const totalIssues = categoriesWithStatus.reduce((sum, c) => sum + c.issues.length, 0) + const hasOrg = Boolean(org) + // Whether the currently-prefilled setup org is the one the user explicitly + // saved as their default. Drives the ⭐ control's active state on first paint. + const isSavedDefault = Boolean(savedDefaultOrg) && orgDefault === savedDefaultOrg + + // Setup-form org control. find_organizations reliably enumerates the orgs this + // Sentry connection can access, so we prefill the slug input with the best match + // (orgDefault) and, when there's more than one org, add a native ` + + orgSlugs + .map((slug) => ``) + .join('') + + `` + : '' + + // Project autocomplete source. The Sentry CLI SDK returns the org's full + // project list, so the project field is a combobox (a text input + a filtered + // suggestion menu) rather than a typed slug box β€” scalable to orgs with many + // projects. Empty value = all projects. Rendered client-side; the server just + // seeds the input value and an empty menu container. + const projectSlugs = Array.isArray(projects) ? projects.filter(Boolean) : [] + + // A text input + suggestion menu wrapper. Shared shape for the setup screen and + // the header so the autocomplete JS can attach to either by id. + const projectComboInput = (id, cls, extraAttrs = '') => + `` + + `` + + `` + + // Header org control. Mirrors the project switcher so the org can be changed + // without returning to the setup screen. Renders as a dropdown when the + // connection exposes 2+ orgs (matching the setup screen), else a text input. + const headerOrgSlugs = !org || orgSlugs.includes(org) ? orgSlugs : [org, ...orgSlugs] + const orgSwitcherControl = headerOrgSlugs.length >= 2 + ? `` + : `` + const orgSwitcher = hasOrg + ? `` + : '' + + // Header project switcher: an autocomplete combobox + an explicit Fetch button. + // Picking a suggestion re-scans that project immediately; a typed value applies + // on Enter or Fetch (empty = all projects). + const projectSwitcher = hasOrg + ? `` + : '' + + // Time-window switcher. Defaults to the last 24h; changing it re-scans over the + // wider window so the user can look further back from the issues list. + const periodList = Array.isArray(periods) ? periods : [] + const periodSwitcher = hasOrg + ? `` + : '' + + // Registered app projects for the Local hand-off target dropdown. Loaded + // eagerly on open via the agent, so this may be empty on the very first render. + const projectList = Array.isArray(projectOptions) ? projectOptions : [] + const localProjectId = prTargets && prTargets.local ? (prTargets.local.projectId || '') : '' + const projectOptionsHtml = projectList + .map((p) => { + const label = p.repo ? `${p.name} (${p.repo})` : p.name + return `` + }) + .join('') + + return ` + + + + + + + + + + + ${!hasOrg ? ` +
+

Triage your live Sentry errors

+

Surfaces the issues that need attention right now. Set your org, project, and repo, then scan.

+
    +
  1. Org & project β€” scope the scan.
  2. +
  3. Repo β€” lets us flag issues already being worked on (πŸ‘€ Tracked).
  4. +
  5. Scan β€” then open a tracking issue, or start a Copilot fix session with a draft PR.
  6. +
+
+
+
+ + +
+
+ + Saved on this machine to ~/.copilot/sentry-triage/preferences.json +
+
+ ` : ` +

On-call error triage for ${escapeHtml(org)}${project ? ` / ${escapeHtml(project)}` : ''} β€” issues grouped by why they need your attention right now.

+

${escapeHtml(subtitleText(totalIssues, scannedTotal, scannedCapped))}${project ? '' : ' Β· all projects'}

+ `} + +
+
+
+ + or + + +
+ +
+
+ Summary of the actions above + +
+
+ +
+
+

Repo

+ +

Where we check whether an issue already has a tracking issue or draft PR (shown as πŸ‘€ Tracked), and the default repo for cloud draft PRs. Recommended whenever you set a project.

+
+
+

Open issue

+ +
+
+

Draft PR

+ +

+
+ Local target + +
+ + +
+
+
+ Cloud target +

Runs in the Repo above.

+ +
+ +
+
+ +
+ +
+ +
+ + Showing ${plainEnglishView ? 'plain-English summaries' : 'raw errors'} +
+ +
+ ${categoriesWithStatus.map((cat) => Category({ ...cat, plainEnglishView, availableModels })).join('')} +
+ + + + + + + + + +` +} diff --git a/extensions/sentry-triage/copilot-extension.json b/extensions/sentry-triage/copilot-extension.json new file mode 100644 index 00000000..dd8ded9a --- /dev/null +++ b/extensions/sentry-triage/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "sentry-triage", + "version": 1 +} diff --git a/extensions/sentry-triage/escape.mjs b/extensions/sentry-triage/escape.mjs new file mode 100644 index 00000000..5191a4e9 --- /dev/null +++ b/extensions/sentry-triage/escape.mjs @@ -0,0 +1,70 @@ +// Shared HTML-escaping helpers. All Sentry / MCP / agent-derived values are +// interpolated into HTML string templates, so every dynamic text or attribute +// value MUST pass through here before it reaches the webview. + +const HTML_ENTITIES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +} + +/** Escape a value for use as HTML text or a double/single-quoted attribute. */ +export function escapeHtml(value) { + if (value == null) return '' + return String(value).replace(/[&<>"']/g, (ch) => HTML_ENTITIES[ch]) +} + +/** + * Return a safe href: only http(s) URLs are allowed through, everything else + * (javascript:, data:, malformed, etc.) collapses to '#'. The result is still + * attribute-escaped for embedding. + */ +export function safeHref(value) { + if (value == null) return '#' + const raw = String(value).trim() + try { + const url = new URL(raw) + if (url.protocol === 'http:' || url.protocol === 'https:') { + return escapeHtml(url.href) + } + } catch { + // not an absolute URL β€” fall through + } + return '#' +} + +/** + * Serialize a value as JSON safe to embed inside an inline + +` +} diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/copilot-extension.json b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/copilot-extension.json new file mode 100644 index 00000000..dd8ded9a --- /dev/null +++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "sentry-triage", + "version": 1 +} diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/escape.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/escape.mjs new file mode 100644 index 00000000..5191a4e9 --- /dev/null +++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/escape.mjs @@ -0,0 +1,70 @@ +// Shared HTML-escaping helpers. All Sentry / MCP / agent-derived values are +// interpolated into HTML string templates, so every dynamic text or attribute +// value MUST pass through here before it reaches the webview. + +const HTML_ENTITIES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +} + +/** Escape a value for use as HTML text or a double/single-quoted attribute. */ +export function escapeHtml(value) { + if (value == null) return '' + return String(value).replace(/[&<>"']/g, (ch) => HTML_ENTITIES[ch]) +} + +/** + * Return a safe href: only http(s) URLs are allowed through, everything else + * (javascript:, data:, malformed, etc.) collapses to '#'. The result is still + * attribute-escaped for embedding. + */ +export function safeHref(value) { + if (value == null) return '#' + const raw = String(value).trim() + try { + const url = new URL(raw) + if (url.protocol === 'http:' || url.protocol === 'https:') { + return escapeHtml(url.href) + } + } catch { + // not an absolute URL β€” fall through + } + return '#' +} + +/** + * Serialize a value as JSON safe to embed inside an inline