# P481765463 — Untrusted spec hover

Ticket: <https://t.corp.amazon.com/P481765463>
Related: <https://t.corp.amazon.com/P411421649>
Date-for-date: 8/3

## Summary

A malicious Kiro Spec in an untrusted workspace can achieve one-click,
arbitrary workbench command execution while Kiro is in Restricted Mode.
Spec **hover providers** in `kiro-extension` mark their entire
`MarkdownString` as `isTrusted = true`, then inject workspace-controlled
text (`userStory.text`, `criteria.text`, `property.title`,
`property.description`, `task.text`, `pbtInfo.*`, `scenario.description`)
into it verbatim. Any `[label](command:…)` embedded in the spec becomes a
live command link when the user hovers over a referencing line —
including in Restricted Mode, because the extension declares
`capabilities.untrustedWorkspaces.supported: true`.

The severity depends on which command the attacker chose to invoke. Every
workbench command that is registered at the time the victim hovers is a
candidate — including private underscore-prefixed commands like
`_remoteCLI.openExternal` (which hands URIs to the OS) and command-
multiplexers like `runCommands` (which fires arbitrary chains of workbench
commands from a single link).

The markdown preview does **not** fire the same link — its click handler
in `extensions/markdown-language-features/preview-src/index.ts` allows
only `http:`, `https:`, `mailto:`, `vscode:`, and `vscode-insiders:`
schemes, so `command:` URIs are silently dropped regardless of workspace
trust. The bug is specific to the hover surface.

## Evidence

### Markdown preview drops `command:` links unconditionally

`extensions/markdown-language-features/preview-src/index.ts` around line 328:

```ts
const passThroughLinkSchemes = ['http:', 'https:', 'mailto:', 'vscode:', 'vscode-insiders:'];

document.addEventListener('click', event => {
    …
    if (passThroughLinkSchemes.some(scheme => hrefText.startsWith(scheme))) { return; }
    if (!/^[a-z\-]+:/i.test(hrefText)) { messaging.postMessage('openLink', ...); }
    return;
});
```

`command:` matches `/^[a-z\-]+:/` but isn't in the passthrough list, so the
handler falls through and does nothing. There is no path from a preview click
to `command:` execution.

### Hover renders and executes `command:` links when `isTrusted`

`src/vs/base/browser/markdownRenderer.ts` (~line 340):

```ts
if (!href
    || /^data:|javascript:/i.test(href)
    || (/^command:/i.test(href) && !markdown.isTrusted)
    || /^command:(\/\/\/)?_workbench\.downloadResource/i.test(href)) {
    // drop the link
    el.replaceWith(...el.childNodes);
} else {
    …
    el.dataset.href = resolvedHref;
}
```

`src/vs/platform/markdown/browser/markdownRenderer.ts` line 94/103:

```ts
allowCommands: toAllowCommandsOption(isTrusted),
…
function toAllowCommandsOption(isTrusted) {
    if (isTrusted === true) return true;           // allow all commands
    if (isTrusted && Array.isArray(isTrusted.enabledCommands)) {
        return isTrusted.enabledCommands;          // allow only allowlist
    }
    …
}
```

`src/vs/editor/browser/services/openerService.ts` `CommandOpener.open`:

```ts
if (!matchesScheme(target, Schemas.command)) return false;
if (!options?.allowCommands) return true;          // silently ignore
if (Array.isArray(options.allowCommands)) {        // allowlist check
    if (!options.allowCommands.includes(target.path)) return true;
}
…
await this._commandService.executeCommand(target.path, ...args);
```

There is no workspace-trust gate on this path. `isTrusted` is a per-
`MarkdownString` flag, not a per-workspace flag. Extensions that keep
running in Restricted Mode can still hand out fully-trusted hovers.

**Hover invalidation.** `provideHover` runs fresh on every hover event —
`getHoverProviderResultsAsAsyncIterable` in
`markdownHoverParticipant.ts:169` re-invokes the registered provider each
time, so reading `vscode.workspace.isTrusted` inline picks up the current
value. Nothing is cached across hover invocations at either the ext-host or
main-thread layer. What _does_ persist is the currently-displayed hover
widget's DOM: its `data-href` attributes and trust-gated drops in
`markdownRenderer.ts:340-355` were computed at render time, so a visible
hover keeps its render-time state until it closes. VS Code's default
hover service (`hoverService.ts:263`) dismisses widgets on document
mousedown outside their bounds, which covers the common cases of "user
opened the workspace-trust dialog while a hover was showing."

### Extension hover providers inject workspace-controlled text into trusted markdown

Four hover providers in `src/extension/spec-editor/` set `isTrusted = true`
on a `MarkdownString` and inject workspace-controlled text into it:

| Provider                                           | Registered on              | Injected workspace text                                                                              | Actual `command:` links rendered                                          |
| -------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `requirements-hover-provider.ts`                   | `.kiro/specs/**/*.md`      | `userStory.text`, `criteria.text`                                                                    | `kiro.navigateToRequirement`                                              |
| `property-hover-provider.ts`                       | `.kiro/specs/**/design.md` | `property.title`, `property.description`, `task.text`, `pbt.propertyTitle`                           | `kiro.navigateToTask`, `kiro.navigateToPBT`, `kiro.navigateToRequirement` |
| `pbt-hover-provider.ts` (via `pbt-hover-utils.ts`) | `.kiro/specs/**/*.md`      | `pbtInfo.taskTitle`, `pbtInfo.propertyDescription`, `pbtInfo.failingExample`, `pbtInfo.codeLocation` | `kiro.navigateToPBT`                                                      |
| `reasoning/scenario-hover-provider.ts`             | scenario doc               | `scenario.description` (inside a fenced code block)                                                  | _none_                                                                    |

All three files listed as rendering command links do the same shape:

```ts
const markdown = new vscode.MarkdownString();
markdown.isTrusted = true;
markdown.supportHtml = true;
…
markdown.appendMarkdown(`**User Story:** ${userStory.text}\n\n`);
markdown.appendMarkdown(`- **${criteriaNumber}.** ${criteria.text}\n`);
```

The injected fields are raw substrings from the workspace's `.kiro/specs/**`
files, produced by `requirements-parser.ts::parseMarkdownRequirements` and
the trace database. No escaping, no sanitization.

`scenario-hover-provider.ts` sets `isTrusted = true` but never renders any
`command:` link — the flag is unnecessary. Left as-is, it is a footgun for
any future contributor that decides to add a template like
`[link](command:...)` to the same file.

### The complete set of `command:` URIs used across kiro-extension hovers

Every embedded `command:` link in any `MarkdownString` in the entire
`kiro-extension/src` tree resolves to one of three commands, all defined in
`property-hover-provider.ts::registerPropertyHoverProvider`:

- `kiro.navigateToTask` — opens `tasks.md`, seeks to a line
- `kiro.navigateToPBT` — opens a PBT source file, seeks to a line
- `kiro.navigateToRequirement` — opens `requirements.md` / `bugfix.md`,
  seeks to a line

All three are pure navigation: `openTextDocument → showTextDocument → set
selection → revealRange`. They form a natural, small, stable allowlist.

They are not entirely side-effect-free, though, and the allowlist shouldn't
be read as "these commands are safe with any arguments." Each takes an
absolute path and opens it in an editor, so a link that reached
`CommandOpener` with attacker-chosen arguments could display an arbitrary
local file. That is a much smaller primitive than arbitrary command
execution, but it means the renderer-level allowlist is a backstop rather
than a guarantee — escaping the injected text is what actually carries the
defense, and the allowlist limits the blast radius if escaping is ever
bypassed.

### Extension activates in Restricted Mode

`package.json`:

```json
"capabilities": {
    "untrustedWorkspaces": {
        "supported": true,
        "description": "Kiro is for everywhere"
    }
}
```

Hover providers and every command are active regardless of workspace
trust.

## Attack chain

1. Attacker distributes a workspace containing
   `.kiro/specs/foo/requirements.md`. One of the acceptance-criteria lines
   contains a markdown link with a `command:` URI targeting a sensitive
   workbench command:
   `[click](command:workbench.action.terminal.sendSequence?...)`,
   `[click](command:runCommands?...)`, or similar.
2. Victim opens the workspace. Kiro prompts for workspace trust; victim
   answers **No**, staying in Restricted Mode.
3. Victim opens `.kiro/specs/foo/tasks.md`. Hovering over a task line
   containing `_Requirements: 1.1_` triggers
   `RequirementsHoverProvider.provideHover`.
4. The provider reads `requirements.md`, injects the malicious criteria
   text into a `MarkdownString` with `isTrusted = true`, and returns it.
5. VS Code renders the hover. The `command:` link survives because
   `markdown.isTrusted` is true.
6. Victim clicks the link. `CommandOpener` fires the command — with
   whatever side effects it has.

## Proposed fix

Narrow spec-hover trust and escape injected text.

**Files:**

- `src/extension/spec-editor/utils.ts` (shared helpers)
- `src/extension/spec-editor/requirements-hover-provider.ts`
- `src/extension/spec-editor/property-hover-provider.ts`
- `src/extension/spec-editor/pbt-hover-provider.ts`
- `src/extension/spec-editor/pbt-hover-utils.ts` (code-location labels)
- `src/extension/spec-editor/reasoning/scenario-hover-provider.ts`
- `src/extension/spec-editor/hover-test-helpers.ts` (test-only)

The hovers only need to fire three Kiro-owned navigation commands. Narrow
`isTrusted` to that allowlist, additionally gate on workspace trust so no
command links at all render in Restricted Mode, and escape all
workspace-derived text before appending.

### Shared helpers (`src/extension/spec-editor/utils.ts`)

```ts
export const KIRO_SPEC_HOVER_COMMANDS = [
  'kiro.navigateToPBT',
  'kiro.navigateToRequirement',
  'kiro.navigateToTask',
] as const;

/**
 * Creates a MarkdownString appropriate for spec-editor hovers.
 *
 * - Only allows the three Kiro-owned navigation commands to fire from any
 *   `[label](command:...)` link in the rendered hover.
 * - Drops command links entirely in untrusted workspaces (Restricted Mode).
 * - Raw HTML is opt-in per hover (see below); theme icons are always on.
 *
 * Callers MUST still escape any workspace-derived text with
 * `escapeMarkdownForHover` before appending it; this helper only limits
 * which commands can fire if a link *does* survive rendering.
 */
export function createSpecHoverMarkdown(options: SpecHoverMarkdownOptions = {}): vscode.MarkdownString {
  const markdown = new vscode.MarkdownString();
  markdown.isTrusted = vscode.workspace.isTrusted ? { enabledCommands: [...KIRO_SPEC_HOVER_COMMANDS] } : false;
  markdown.supportHtml = options.supportHtml ?? false;
  markdown.supportThemeIcons = true;
  return markdown;
}

/**
 * Escapes the characters that markdown uses to open link, image,
 * reference, HTML, and autolink constructs, so that workspace-derived
 * text can be safely spliced into `appendMarkdown` without smuggling in
 * clickable command URIs or arbitrary HTML.
 *
 * `[` and `]` alone are not sufficient: VS Code renders `MarkdownString`
 * through `marked`, whose inline-link tokenizer still recognises
 * `\[label\](url)` as a valid link even with backslash-escapes on the
 * brackets, producing a nested `<a>` when spliced into an outer
 * `[Task: ...](command:...)` wrapper. Escaping `(` (or `)`) breaks the
 * `](url)` suffix so `marked` can no longer close the inner link, and the
 * whole substring falls through as literal text. `[` and `]` are still
 * escaped so the label reads correctly, and `<`/`>` are escaped to prevent
 * HTML tag injection.
 *
 * `\` MUST be escaped, and first. Every escape here works by prefixing a
 * backslash, so text that already contains one defeats the escape that
 * follows it: workspace text `\<img src=...\>` would otherwise become
 * `\\<img src=...\\>`, which renders as a literal backslash plus a *raw*
 * `<img>` tag.
 *
 * Deliberately narrow: it does NOT escape `` ` ``, `*`, `_`, `#`, `-`,
 * etc. so that legitimate authorship (bold, italic, inline code,
 * headings, lists) still renders in the hover.
 */
export function escapeMarkdownForHover(text: string): string {
  return text.replace(/[\\[\]()<>]/g, '\\$&');
}

/**
 * Truncates workspace-derived text for display inside a hover, appending
 * an ellipsis when the original exceeds `maxLength` visible characters.
 * Runs before `escapeMarkdownForHover`, so `maxLength` counts visible
 * characters (not backslash-escapes).
 */
export function truncateForHover(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength - 1) + '…';
}
```

Two layered defenses:

- **String level.** `escapeMarkdownForHover` prevents workspace text from
  synthesising _any_ link, image, or HTML tag — including a link whose
  target is an allowlisted command with attacker-crafted args — by
  escaping `\`, `[`, `]`, `(`, `)`, `<`, `>` before content ever reaches
  `appendMarkdown`. Every other markdown construct (bold, italic, inline
  code, headings, lists) still renders normally, so legitimate authorship
  in a user story or acceptance criterion looks right. Two known
  casualties: blockquotes (`>` is escaped to block HTML tags) and
  codicons `$(name)` (the paren escape breaks the syntax), but neither
  matters in practice — blockquotes aren't idiomatic in specs, and
  codicons only appear in provider-controlled strings that never pass
  through this helper.
- **Renderer level.** `enabledCommands` limits any `command:` link that
  might somehow survive (a new provider forgets to escape, a bug in the
  helper, a construct we didn't anticipate) to the allowlist at
  `OpenerService`, so `_remoteCLI.openExternal`, `runCommands`, and any
  other workbench command are dropped.
- **Capability level.** `supportHtml` is opt-in and only the two hovers
  that render a `<span style="...">` status badge (PBT, scenario) ask for
  it. The prose-heavy hovers (requirements, property) inject the most
  workspace text and now run with HTML off, so a tag that ever survives
  escaping there is stripped by the renderer instead of reaching the HTML
  sanitizer.

Combined, a spec containing `[Get free RAM!](command:kiro.navigateToTask?
["…"])` renders as the literal text `\[Get free RAM!\]\(command:...\)`
(no link — the escapes are hidden by the markdown renderer, so the user
sees `[Get free RAM!](command:...)`) in a trusted workspace, and
produces no `command:` DOM node at all in an untrusted one.

**Why the paren escape is load-bearing.** An earlier iteration of this
plan specified escaping only `[`, `]`, `<`, `>` on the assumption of
CommonMark-compliant renderer behavior. That works with the
`markdown-it` parser but fails against VS Code's actual renderer, which
is `marked`. `marked` treats `\[label\](url)` as a valid link — the
backslash-escaped brackets don't break its inline-link tokenizer. Only
disrupting the `](url)` opener (by escaping either `(` or `)`) forces
`marked` to fall back and render the whole thing as literal text. This
was caught during in-product verification, not during unit tests, because
the tests had asserted on the escaped `MarkdownString.value` string
rather than on the effective constructs in it. The
`findLiveCommandLinks` / `findRawHtmlTags` assertions described in the
test plan below close that gap.

**Why the backslash escape is load-bearing.** Review of the first
implementation found the same class of bug one level down. Because every
escape works by _prefixing_ a backslash, workspace text that already
contains one absorbs the prefix: a criteria line containing
`\<img src=https://attacker.example/p.png\>` became
`\\<img src=…\\>`, which `marked` renders as a literal backslash followed
by a **raw** `<img>` tag. `img` is in VS Code's allowed tag list and
`src` in its allowed attributes, with `http`/`https`/`data` allowed media
protocols, so the hover fetched an attacker URL — on hover alone, with no
click and regardless of workspace trust. (`onerror` is stripped by the
sanitizer, so this was a beacon, not script execution.) Escaping `\`
first keeps each prefix bound to the character it protects.

### Per-provider changes

Each provider swaps its manual `MarkdownString` construction for the
helper and wraps injected prose in `escapeMarkdownForHover`:

```diff
-const markdown = new vscode.MarkdownString();
-markdown.isTrusted = true;
-markdown.supportHtml = true;
-markdown.supportThemeIcons = true;
+// `{ supportHtml: true }` only in the PBT and scenario hovers, which
+// render a `<span style="...">` status badge.
+const markdown = createSpecHoverMarkdown();
 …
-markdown.appendMarkdown(`**User Story:** ${userStory.text}\n\n`);
+markdown.appendMarkdown(`**User Story:** ${escapeMarkdownForHover(userStory.text)}\n\n`);
 …
-markdown.appendMarkdown(`- **${criteriaNumber}.** ${criteria.text}\n`);
+markdown.appendMarkdown(`- **${criteriaNumber}.** ${escapeMarkdownForHover(criteria.text)}\n`);
```

Same pattern for `property.title`, `property.description`, `task.text`,
`pbt.propertyTitle`, `pbtInfo.taskTitle`, `pbtInfo.propertyDescription`,
`pbtInfo.codeLocation`.

**For fields that are today spliced inside a manual code fence** —
`pbtInfo.failingExample` in `pbt-hover-provider.ts` and
`scenario.description` in `scenario-hover-provider.ts` — switch to
`MarkdownString.appendCodeblock` instead of manual fencing:

```diff
-markdown.appendMarkdown(`**Failing Example:**\n\`\`\`text\n${pbtInfo.failingExample}\n\`\`\`\n`);
+markdown.appendMarkdown(`**Failing Example:**\n`);
+markdown.appendCodeblock(pbtInfo.failingExample, 'text');
```

`appendCodeblock` handles fence integrity internally (chooses a longer
fence delimiter if the content contains ` ` ```). Inside a code block
no `[`, `]`, `<`, `>` renders as markdown syntax anyway, so no additional
prose escape is needed for these fields.

The `pbt-hover-utils.ts` helpers that construct code-location markdown
snippets need the same treatment: wrap `pbt.propertyTitle` and the
displayed `pbt.filePath:pbt.lineNumber` label in `escapeMarkdownForHover`.
Filenames in a malicious workspace can be attacker-controlled too.

**`scenario-hover-provider.ts`** now uses `createSpecHoverMarkdown` (same
allowlisted-`enabledCommands` treatment as the three navigation hovers)
rather than dropping `isTrusted` entirely. Keeping the four providers
uniform means a future contributor who adds a `command:` link to any of
them lands on the same three-command allowlist by default, so the
"footgun" pattern is closed at the type level. The scenario description
itself goes through `appendCodeblock`, so no workspace text lands in a
link context anyway.

**`supportHtml` is requested per hover**, not granted by the helper. Only
`pbt-hover-provider.ts` and `scenario-hover-provider.ts` emit raw HTML (a
`<span style="...">` status badge), so only they pass
`{ supportHtml: true }`. `requirements-hover-provider.ts` and
`property-hover-provider.ts` emit markdown only, and both previously set
`supportHtml = true` without needing it.

### Truncate workspace-derived labels and titles

Long workspace strings — legitimate long task descriptions and
attacker-padded payloads alike — visibly blow out the hover width.
Escaping keeps them safe, but doesn't keep them compact. The
`truncateForHover(text, maxLength)` helper handles the truncation, and
runs _before_ `escapeMarkdownForHover` so `maxLength` counts
user-visible characters (not backslash-escapes).

Two production call sites drive the choice of `maxLength`:

- `property-hover-provider.ts` — implementation-task labels in the
  `Implementation Tasks:` section are capped at 60 chars
  (`TASK_LABEL_MAX_LENGTH`). Each list entry is a
  `[Task: <truncated>](command:kiro.navigateToTask?...)` link.
- `pbt-hover-provider.ts` — the PBT task title (`pbtInfo.taskTitle`,
  rendered as `**Task:** <title>`) is capped at 200 chars
  (`TASK_TITLE_MAX_LENGTH`). The title renders as a full sentence rather
  than a list entry, so it needs more room.

Both constants live next to the class that uses them, not in `utils.ts`,
because the "right" length is a UI concern of the specific hover surface.

The `command:kiro.navigateToTask` URL still receives the untruncated
task id, so clicking a truncated label still navigates to the right task.

### Tradeoff we're choosing

We are choosing "hover renders no command links in Restricted Mode" over
"hover disables entirely in Restricted Mode." The latter would mean
requirement/PBT/task hovers show _nothing_ in an untrusted workspace,
which hurts the legitimate case of "user is inspecting an untrusted repo
to decide whether to trust it." Keeping the informational content — just
with no clickable navigation — matches the spirit of Restricted Mode.

### On workspace-trust transitions and stale hovers

`provideHover` re-runs on every hover event, so reading
`vscode.workspace.isTrusted` inline (which is what `createSpecHoverMarkdown`
does) picks up trust changes on the next hover with no explicit
invalidation required. Nothing is cached across invocations at either the
ext-host or main-thread layer.

The one edge case is a currently-_visible_ hover widget: its rendered DOM
was computed at render time and doesn't self-update when trust flips. In
practice VS Code's hover service closes the widget on document mousedown
outside its bounds, which covers the common case of "user opens the
workspace-trust dialog while a hover is visible." A visible hover that
survives a trust grant will show its untrusted-state rendering (no nav
links) until the user moves their mouse and the next hover renders fresh
— a minor UX bug, not a security issue, because the only commands the
trusted-state hover would fire are the three allowlisted navigation
commands.

If we want to close that residual gap as UX polish, register in the
provider's activation:

```ts
context.subscriptions.push(
  vscode.workspace.onDidGrantWorkspaceTrust(() => {
    // Force any currently-visible hover to re-render with new trust
    // state. Uses the public showHover command which triggers a
    // re-computation at the current caret; if no hover is visible
    // this is a no-op.
    void vscode.commands.executeCommand('editor.action.showHover');
  }),
);
```

Not required for the security fix. Note that `onDidGrantWorkspaceTrust`
is one-way — the extension API doesn't expose revocation, and revocation
typically triggers an ext-host restart anyway, so a "revoke" listener
isn't a design gap we need to plug.

## Longer-term hardening

Not required for the fix, but worth filing follow-ups:

- After this change, `createSpecHoverMarkdown` is the only place in
  `kiro-extension/src` that sets a truthy `isTrusted` on a
  `MarkdownString` (verified by grep for both `isTrusted =` and
  `isTrusted:`), so there is no second instance of this bug in the
  extension today. Worth a lint rule to keep it that way: ban
  `isTrusted = true` outside the shared helper, so a new surface has to
  opt into an allowlist rather than full trust.
- Reconsider `capabilities.untrustedWorkspaces.supported: true` in
  `package.json`. Downgrade to `"limited"` and enumerate the surfaces we
  intentionally keep active. Spec editing UX in an untrusted workspace is
  dubious value — the whole point of Restricted Mode is that the user
  hasn't decided to trust the workspace yet.
- Propose upstream that VS Code apply `enabledCommands` at the ext-host
  boundary based on the extension's declared commands, so
  `_remoteCLI.openExternal` and other underscore-prefixed private
  commands are not reachable from extension-authored markdown by default.
- Ban `_remoteCLI.openExternal` and `runCommands` from
  extension-authored trusted markdown outright (either by patching the
  command opener or by adding a hard-coded denylist). Neither has a
  legitimate consumer in extension markdown.

## Test plan

### End-to-end and manual verification

- Reproduce a hover-based command-execution attack end-to-end on a build
  with the fix applied; confirm no `command:` link fires except for the
  three allowlisted `kiro.navigateTo*` commands, and only in a trusted
  workspace.
- Verify by inspecting the _rendered_ hover DOM (not just the
  `MarkdownString.value` string) that the anchor's `href` is the
  extension's `command:kiro.navigateToTask?…`, not any attacker
  `command:` URL smuggled through the injected text. This gap in
  verification is what allowed a bracket-only escape to ship as "safe"
  before the paren escape was added.
- Confirm no hover issues an outbound network request for a spec
  containing `\<img src=https://…\>` in a criteria line — the raw-tag
  bypass that the backslash escape closes. Watch the network panel while
  hovering rather than reading the markdown.
- Regression coverage: navigating from a task line to its requirement,
  from a design property to a linked task, from a task to a PBT — all
  still work via hover in a **trusted** workspace.

### Unit tests — asserting on effective constructs

Substring assertions on `MarkdownString.value` are what let the
bracket-only escape look safe: `\[click\](command:evil)` still contains
`\[click\]`, so a test asserting on that passed while the hover rendered
a live attacker link. `hover-test-helpers.ts` provides two helpers that
answer the question the substring assertions could not:

- `findLiveCommandLinks(value)` — command ids of every `(command:…)`
  destination that could fire, skipping escaped parens.
- `findRawHtmlTags(value)` — tag names of every raw `<tag>` (and
  autolink) that survives, skipping escaped angle brackets.

Both use backslash-run **parity** to decide whether a construct's opening
character is escaped, so they catch a payload that neutralises our escape
with a backslash of its own. They deliberately do not run `marked`: it
isn't a direct dependency, and the version resolvable from `node_modules`
differs from the one kiro-desktop bundles, so a `marked`-based test would
assert against a renderer that isn't the one in production. Instead they
encode the two renderer behaviors the fix relies on — an escaped `<`
can't open a tag, and an escaped `(` can't open a link destination, while
an escaped `]` proves nothing because `marked` tokenizes `\[a\](url)` as
a link regardless.

`hover-test-helpers.test.ts` covers the helpers themselves, including
positive detection of both historical bypasses (bracket-only escape,
backslash-prefixed tag), so a broken helper can't silently make every
hover test vacuous.

### Unit tests — spec-editor hovers

- `requirements-hover-provider.test.ts`:
  - Attacker-shaped `criteria.text`
    (e.g. `` `[a](command:_remoteCLI.openExternal?["kiro:%2F%2F..."])` ``)
    and `userStory.text`: the only live command link is the provider's own
    `kiro.navigateToRequirement` heading link, no raw tags, and the
    payload is still visible as literal text.
  - `criteria.text` containing `\<img src=…\>`: no raw tags, and the
    hover's `supportHtml` is `false`.
- `property-hover-provider.test.ts`:
  - Attacker payload injected into `property.title`,
    `property.description`, `task.text`, and `pbt.propertyTitle`: every
    live command link is in `KIRO_SPEC_HOVER_COMMANDS`, no raw tags, and
    each field's `\[click\]` appears in the escaped output.
  - Truncation: an implementation task with a 200-char `task.text` is
    rendered as a `[Task: …](command:kiro.navigateToTask?…)` link whose
    label is exactly 60 chars and ends with `…`. The link's URL still
    contains the untruncated task id.
- `pbt-hover-provider.test.ts`:
  - Attacker payload in `pbtInfo.taskTitle` and
    `pbtInfo.propertyDescription`: the only live link is
    `kiro.navigateToPBT` and the only raw tags are the status badge's
    `span` pair.
  - Truncation: a 400-char `pbtInfo.taskTitle` renders as `**Task:**`
    followed by a 200-char string ending in `…`.
  - `pbtInfo.failingExample` containing embedded ` ``` ` markers is
    rendered inside a code block that `appendCodeblock` lengthens the
    fence for, so no adversarial content escapes the block.
- `pbt-hover-utils.test.ts`: a `pbt.filePath` that is itself a markdown
  command link yields a code-location snippet whose only live link is
  `kiro.navigateToPBT`, with the provider-derived `:lineNumber` still
  outside the escape.

### Unit tests — `utils.test.ts`

- `escapeMarkdownForHover`:
  - Link syntax `[a](url)` — escaped as `\[a\]\(url\)`.
  - Image syntax `![a](url)` — `[`, `]`, `(`, `)` all escaped.
  - Reference-style link brackets `[a][b]`.
  - Autolinks `<http://...>`.
  - Inline HTML tags including `(` and `)` in attributes.
  - Backslash-prefixed payloads (`\<img …\>`, `\[a\]\(url\)`) — the
    backslash is escaped too, and the result has no live tag or link
    construct per the helpers above.
  - A Windows-style path (`C:\Users\me\spec.md`) still reads correctly
    once the escaped backslashes render.
  - Positive assertions that bold `**...**`, italic `_..._`, inline
    code `` `...` ``, headings `# ...`, and unordered lists render
    unchanged in the escaped output.
  - Explicit note that codicons `$(name)` are escaped to `$\(name\)` as
    a documented side effect of the paren escape.
- `truncateForHover`:
  - Input unchanged when shorter than / equal to `maxLength`.
  - Empty string unchanged.
  - Longer input truncated to exactly `maxLength` with a trailing `…`.
  - One test at `maxLength: 60` (task-label site) and one at
    `maxLength: 200` (PBT-title site) so both production values are
    exercised at the unit level.
- `createSpecHoverMarkdown`:
  - Trusted workspace → `isTrusted` is an object whose `enabledCommands`
    equals the three-command allowlist.
  - Untrusted workspace → `isTrusted === false`, with and without the
    `supportHtml` opt-in.
  - `supportHtml` defaults to `false` and is only enabled when the caller
    opts in; `supportThemeIcons` is always enabled.
  - **Dangerous-commands denylist.** `KIRO_SPEC_HOVER_COMMANDS` does not
    include `_remoteCLI.openExternal` or `runCommands`. Designed to grow
    as new exploit primitives are discovered.
