# P481765463 — Unguarded MCP install URI

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

## Summary

The `kiro://kiro.mcp/add` URI handler in
`src/extension/mcp/mcp-utility-commands.ts` writes an attacker-supplied
MCP server name and shell command into `~/.kiro/settings/mcp.json` with
only a dismissable non-modal "would you like to enable it?" notification
guarding the enable step. The write itself happens silently (aside from
opening the config file in an editor tab), the follow-up notification is
dismissable via `notifications.clearAll`, and the just-added disabled
server can be armed by `kiroAgent.mcp.enableAllServers` — a legitimate
registered command that reads current config state and flips
`disabled: false` on every server it finds, without per-server consent.
Once armed, the malicious server executes on every subsequent session
start; the config persists in `~/.kiro/settings/mcp.json` and re-runs on
every launch until the user notices and manually removes it.

The URI handler is reachable from any source that can dispatch a
`kiro://` URI to the running Kiro instance — a browser tab, an OS
deep-link handler, a docs page rendered anywhere, a chat panel, another
extension's markdown surface, or a chain of workbench commands invoked
from an untrusted markdown context. The design intent is to support
external one-click MCP-install deep-links (see below), but the same
reachability means any untrusted content that can fire a URI is a vector.

The extension declares `capabilities.untrustedWorkspaces.supported: true`,
so the URI handler is active regardless of workspace trust.

## Evidence

### MCP install URI handler installs with only a soft-dismissable prompt

`src/extension/mcp/mcp-utility-commands.ts`:

```ts
uriEventHandler.onUri(async (uri) => {
  if (uri.authority !== 'kiro.mcp' || uri.path !== '/add') return;

  const params = new URLSearchParams(uri.query);
  const name = params.get('name');
  const config = params.get('config');
  if (!name || !config) throw new Error('Missing required parameters: name or config');

  mcpLogger.info(`Adding server ${name} with config ${config}`, name);
  await addMCPServerConfig(name, config);
});
```

`addMCPServerConfig` in `packages/kiro-shared/src/mcp/config.ts:407-455`:

```ts
export async function addMCPServerConfig(serverName: string, config: string): Promise<void> {
    await vscode.commands.executeCommand('kiroAgent.openUserMcpConfig');    // opens the file
    …
    newServerConfig = { ...parsed, disabled: true };                        // writes as disabled
    fs.writeFileSync(userConfigLocation.userConfigPath, JSONC.stringify(newConfig, null, 2));
    const selection = await vscode.window.showInformationMessage(           // non-modal
        `MCP Server "${serverName}" added to user config, would you like to enable it?`,
        'Enable',
    );
    if (selection === 'Enable') setMCPServerDisabled(serverName, false);
}
```

The current mitigation is real but shallow:

1. The config file opens in an editor tab (a visual signal, but non-blocking and easy to miss during a burst of UI churn).
2. The server is added with `disabled: true`, so it won't spawn on install.
3. A non-modal `showInformationMessage` asks the user to enable.

All three are defeated in a chained-command attack:

- Signal #1 is buried under other UI events.
- Signal #3 is dismissed by `notifications.clearAll` before the user can
  react.
- Signal #2 is neutralized by `kiroAgent.mcp.enableAllServers`, which
  reads current config state and flips `disabled: false` on every server
  it finds. That's a legitimate command (there's a UI action for it in
  `package.json`), but it operates without per-server consent, so any
  server the URI handler has just added silently is armed as a side
  effect.

### Design intent of the URI handler

The URI handler exists to support **external one-click MCP-install
deep-links** — a docs page, a third-party MCP registry, or a blog post
that offers `kiro://kiro.mcp/add?name=...&config=...` as an install
button. This is the analog of `vscode:extension/foo` for extension
installs. Introduced in commit `b615e5b82` (feat: Add command for adding
MCP server, to be used via deeplink (#2396)) and refactored to the
URI-handler shape in `24eb1b60d`.

**No caller inside this repo constructs a `kiro://kiro.mcp/add` URI.** The
URI event bus is the only invocation path. That means we're free to
change the handler's consent shape without breaking internal callers;
existing external deep-links continue to work, they just now surface a
modal.

### Extension activates in Restricted Mode

`package.json`:

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

The URI handler is active regardless of workspace trust.

## Attack chain

1. Attacker gets a
   `kiro://kiro.mcp/add?name=…&config=%7B%22command%22%3A%22/bin/sh%22%2C%22args%22%3A%5B%22-c%22%2C%22…%22%5D%7D`
   URI dispatched to the running Kiro instance. Any source that can fire
   a URI works: a docs page, an OS-level link, another extension's
   markdown surface, a browser tab the user has open, or a chain of
   workbench commands invoked from an untrusted markdown context. The
   URI event bus is the only invocation path into the handler, but it's
   reachable from many places.
2. The URI handler runs `addMCPServerConfig(name, config)`, which opens
   the config file in an editor tab, writes the malicious server as
   `disabled: true`, and shows a non-modal information message asking
   whether to enable.
3. In the same command sequence that triggered the URI, the attacker's
   payload calls `notifications.clearAll` to dismiss the "would you like
   to enable?" prompt before the user can react, and
   `kiroAgent.mcp.enableAllServers` to flip the just-added disabled
   server to enabled.
4. On the next session start (or immediately, once the current session
   picks up the config change), Kiro loads the server and runs the
   attacker's shell command. The config persists in
   `~/.kiro/settings/mcp.json`, so this repeats every session start
   until the user notices and manually removes it.

## Proposed fix

Gate `kiro://kiro.mcp/add` behind a Yes/No modal at install time.

**File:** `src/extension/mcp/mcp-utility-commands.ts`

The URI handler is the only sink an attacker can reach. Wrap it with a
modal Yes/No prompt that surfaces the exact command that will run. Only
call `addMCPServerConfig` after explicit confirm. Leave
`addMCPServerConfig` itself unchanged — the server continues to install
as `disabled: true`, and the existing non-modal follow-up "would you
like to enable it?" prompt still gates the enable step.

Two-stage consent by design:

1. **Modal at install time.** Not dismissable by `notifications.clearAll`.
   Kills the drive-by install primitive — the attacker's payload can no
   longer write to `mcp.json` silently.
2. **Existing non-modal notification at enable time.** Left as-is.
   Attackers have no way to reach it silently once the install modal is
   in place: their payload's `notifications.clearAll` calls fire in the
   synchronous command chain and clear existing notifications _before_
   the URI handler runs, not the enable prompt that appears _after_ the
   modal is dismissed. And by the time the modal is dismissed, the
   payload's chained `kiroAgent.mcp.enableAllServers` calls have already
   fired against a config that didn't yet contain the malicious server —
   they're no-ops for it.

Sketch:

```ts
// src/extension/mcp/mcp-utility-commands.ts
uriEventHandler.onUri(async (uri) => {
  if (uri.authority !== 'kiro.mcp' || uri.path !== '/add') return;

  const params = new URLSearchParams(uri.query);
  const name = params.get('name');
  const config = params.get('config');
  if (!name || !config) throw new Error('Missing required parameters: name or config');

  let parsed: Record<string, unknown>;
  try {
    const rawParsed = JSONC.parse(config);
    if (!rawParsed || typeof rawParsed !== 'object' || Array.isArray(rawParsed)) {
      throw new Error('Config must be a JSON object');
    }
    parsed = rawParsed as unknown as Record<string, unknown>;
  } catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    mcpLogger.info(`Rejected MCP install URI for ${name}: invalid config JSON (${message})`, name);
    void vscode.window.showErrorMessage(`Cannot install MCP server "${name}": invalid config JSON (${message}).`);
    return;
  }

  const detail = renderMCPConfigForReview(parsed);
  const confirm = await vscode.window.showWarningMessage(
    `Install MCP server "${name}"?`,
    { modal: true, detail },
    'Install',
  );
  if (confirm !== 'Install') {
    mcpLogger.info(`User declined MCP install URI for ${name}`, name);
    return;
  }

  mcpLogger.info(`Adding server ${name} with config ${config}`, name);
  await addMCPServerConfig(name, config);
});
```

`renderMCPConfigForReview(parsed)` formats:

- **stdio transport:** `command`, `args` (joined), every `env` key
  (values omitted), and `cwd` when present.
- **SSE / HTTP transport:** `url` and every header key (values omitted —
  a header value that carries a bearer token shouldn't be legible in
  the modal, but the key name lets the user see if anything unexpected
  is being sent).
- **Shell-interpreter warning banner.** If the `command` basename is one
  of `sh`, `bash`, `zsh`, `dash`, `fish`, `ksh`, `pwsh`, `powershell`,
  `cmd`, the detail block prepends `⚠️ This runs a shell interpreter
and can execute arbitrary commands.` The researcher's payload uses
  exactly this pattern (`/bin/sh -c "..."`), and there is essentially
  no legitimate MCP server distribution that requires spawning an
  ad-hoc shell one-liner — real MCP servers ship as binaries or
  scripts you invoke by path. Kept as a warning rather than a hard
  block so legitimate niche cases (setup scripts, wrappers) still
  install with informed consent.
- **Full config** as a final section — pretty-printed JSON dump with
  `env` and `headers` values replaced by the string `<hidden>` so power
  users can audit the full structure without leaking bearer tokens into
  the modal.

Notes on the design:

- The modal is a `showWarningMessage({ modal: true })`. Not dismissable
  by `notifications.clearAll`.
- `addMCPServerConfig` is left untouched — the server still lands in
  `~/.kiro/settings/mcp.json` as `disabled: true`, and the follow-up
  non-modal enable prompt runs exactly as before. Programmatic callers
  (none in-tree today, but future setup wizards) get the same two-stage
  flow if they call `addMCPServerConfig` directly, which is the right
  default.
- Log the source URI (or extension ID if invoked programmatically) to
  telemetry so we can distinguish drive-by installs from CLI/setup
  flows and monitor for post-fix regression attempts. (Not implemented
  in this fix — a follow-up.)

External deep-links from docs, third-party MCP registries, or blog posts
continue to work. Users see the modal instead of a silent-with-notification
install, which matches the pattern users already know from
`vscode:extension/foo` install flows.

## Longer-term hardening

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

- Consider a **hard block** for stdio configs whose `command` resolves
  to a shell interpreter. Today's fix flags them with a warning banner
  and lets the user proceed with informed consent; a stricter policy
  would refuse to install them at all. There is essentially no
  legitimate MCP server distribution that requires spawning an ad-hoc
  shell one-liner.
- Reconsider `capabilities.untrustedWorkspaces.supported: true` in
  `package.json`. Downgrade to `"limited"` and enumerate the surfaces
  we intentionally keep active. An MCP-mutating URI handler running
  before the user has decided to trust the workspace is dubious value
  — the whole point of Restricted Mode is that they haven't yet.
- Add telemetry on the URI-handler invocation path (source URI or
  extension ID, plus the modal outcome) so we can distinguish drive-by
  installs from CLI/setup flows and monitor for post-fix regressions.
- Consider making `kiroAgent.mcp.enableAllServers` per-server-consent
  rather than a batch flip. It's the primitive that turns "silently
  installed as `disabled: true`" into "silently armed" whenever a new
  attack surface finds its way to it — closing that primitive would
  eliminate a class of future problems even if new install paths ever
  regress.

## Test plan

### End-to-end and manual verification

- Reproduce a chained-command install attack end-to-end on a build with
  the fix applied; confirm the MCP config is never written and no shell
  runs.
- Confirm `notifications.clearAll` in the same command chain cannot
  dismiss the install modal, and that a chained
  `kiroAgent.mcp.enableAllServers` call has nothing to enable when the
  modal is declined (nothing was written to `mcp.json`).
- Legitimate deep-link regression: a well-formed
  `kiro://kiro.mcp/add?...` from a docs page or third-party MCP
  registry surfaces the modal, and clicking "Install" writes the config
  as `disabled: true` and shows the existing non-modal "would you like
  to enable it?" prompt exactly as before. Clicking "Cancel" writes
  nothing.

### Unit tests — `mcp-utility-commands-handlers.test.ts`

- URI handler shows the modal for every well-formed
  `kiro://kiro.mcp/add?...`.
- `addMCPServerConfig` is called exactly once with the parsed name and
  config when the user clicks "Install".
- `addMCPServerConfig` is **not** called when the user cancels or
  dismisses the modal.
- Invalid config JSON surfaces an error message and does **not** show
  the modal.
- Non-object config JSON (e.g. `"[]"`) surfaces an error message and
  does **not** show the modal.
- Modal detail block for stdio configs includes the parsed `command`,
  `args`, and `env` keys — and does NOT include `env` _values_.
- Modal detail block prepends the shell-interpreter warning banner
  when `command` is `/bin/sh` (and other shells).
- Modal detail block for remote configs includes the `url` and header
  keys, and does NOT include header _values_.
