gate clide:// deep links: paranoid allowlist + confirmation prompt (T-56, D-90)

A clide:// link is an untrusted external vector (any webpage can fire one), so it
no longer translates to a command in parseArgv. It routes the raw URL to a new
builtin.deeplink handler that is doubly defensive: a default-deny allowlist
(kDeepLinkSafeActions — only the read-only 'open' verb; run/git/write/passthrough
rejected) AND a mandatory 'an external link wants to: … allow?' confirmation
before anything runs. Records the security boundary as D-90.

The earlier silent editor.open passthrough is replaced; open still works, now
behind the prompt. Tests cover the allowlist (the boundary) + the gating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:57:43 +02:00
co-authored by Claude Opus 4.8
parent 2791776ab7
commit 7171a09fc2
11 changed files with 316 additions and 65 deletions
+5
View File
@@ -0,0 +1,5 @@
/// Built-in `clide://` deep-link handler (T-56, D-90).
library;
export 'src/deep_link.dart';
export 'src/extension.dart';
+51
View File
@@ -0,0 +1,51 @@
/// Parsing + the paranoid allowlist for `clide://` deep links (T-56, D-90).
///
/// A clide:// URL is an UNTRUSTED external vector — any webpage can fire one at
/// the OS handler — so the surface is **default-deny**: only the actions in
/// [kDeepLinkSafeActions] are even parseable, and they are read-only /
/// navigation verbs. Execution is additionally gated by a user prompt (see the
/// deeplink extension). Pure (no Flutter): the allowlist + parsing are unit
/// tested in isolation.
library;
/// The ONLY actions a clide:// link may request. Default-deny: anything not in
/// this set is rejected outright. Keep it to side-effect-free navigation —
/// NEVER writes, process control, git, or arbitrary command passthrough.
const Set<String> kDeepLinkSafeActions = {'open'};
/// A validated, allowlisted deep-link action.
class DeepLinkAction {
const DeepLinkAction({required this.name, required this.path, this.line});
final String name;
final String path;
final int? line;
/// A human-readable description for the confirmation prompt.
String get describe => switch (name) {
'open' => 'Open $path${line != null ? ' (line $line)' : ''}',
_ => name,
};
}
/// Parse [url] into a [DeepLinkAction], or null when it is malformed, not a
/// `clide://` URL, not an allowlisted action, or missing required parameters.
/// Validation only — it never executes anything.
DeepLinkAction? parseDeepLink(String url) {
final uri = Uri.tryParse(url);
if (uri == null || uri.scheme != 'clide') return null;
if (!kDeepLinkSafeActions.contains(uri.host)) return null;
switch (uri.host) {
case 'open':
final path = uri.queryParameters['path'];
if (path == null || path.isEmpty) return null;
int? line;
final lineRaw = uri.queryParameters['line'];
if (lineRaw != null && lineRaw.isNotEmpty) {
line = int.tryParse(lineRaw);
if (line == null || line < 1) return null; // reject junk rather than guess
}
return DeepLinkAction(name: 'open', path: path, line: line);
}
return null;
}
+107
View File
@@ -0,0 +1,107 @@
/// `clide://` deep-link handler (T-56, D-90).
///
/// Registers the `deeplink.invoke` command that the CLI routes a clide:// URL to
/// (via parseArgv). Because a clide:// link is an UNTRUSTED external vector, the
/// handler is doubly defensive: it only honours [kDeepLinkSafeActions]
/// (default-deny) AND it prompts the user before doing anything.
library;
import 'package:clide/builtin/deeplink/src/deep_link.dart';
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class DeepLinkExtension extends ClideExtension {
@override
String get id => 'builtin.deeplink';
@override
String get title => 'Deep links';
@override
String get version => '0.1.0';
ClideExtensionContext? _ctx;
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'deeplink.invoke',
command: 'deeplink.invoke',
title: 'Open a clide:// deep link',
run: _invoke,
),
];
@override
Future<void> activate(ClideExtensionContext ctx) async => _ctx = ctx;
@override
Future<void> deactivate() async => _ctx = null;
Future<IpcResponse> _invoke(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return IpcResponse.ok(id: '', data: const {'status': 'not-activated'});
final url = args.isEmpty ? null : args.first;
final action = url == null ? null : parseDeepLink(url);
// Default-deny: a malformed or non-allowlisted link never acts.
if (action == null) {
return IpcResponse.ok(id: '', data: {'status': 'rejected', 'url': url});
}
// Always confirm — an external page must not silently drive the IDE (D-90).
final ok = await ctx.dialog.show<bool>((c, dismiss) => _DeepLinkConfirmDialog(action: action, onResolve: dismiss));
if (ok != true) return IpcResponse.ok(id: '', data: const {'status': 'declined'});
switch (action.name) {
case 'open':
await ctx.ipc.request('editor.open', args: {'path': action.path, if (action.line != null) 'line': action.line});
return IpcResponse.ok(id: '', data: {'status': 'opened', 'path': action.path});
}
return IpcResponse.ok(id: '', data: const {'status': 'rejected'});
}
}
/// Confirmation modal for an incoming deep link — frames it as untrusted and
/// requires an explicit Open. Cancel (the default) declines.
class _DeepLinkConfirmDialog extends StatelessWidget {
const _DeepLinkConfirmDialog({required this.action, required this.onResolve});
final DeepLinkAction action;
final void Function([bool? result]) onResolve;
@override
Widget build(BuildContext context) {
final t = ClideTheme.of(context).surface;
return ClideSurface(
width: 440,
color: t.modalSurfaceBackground,
border: t.modalSurfaceBorder,
padding: const EdgeInsets.all(16),
borderRadius: BorderRadius.circular(6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('Open an external link?', fontSize: clideFontBody, color: t.globalForeground),
const SizedBox(height: 6),
ClideText('A clide:// link from outside the app is asking to:', muted: true, fontSize: clideFontSmall),
const SizedBox(height: 8),
ClideText(action.describe, fontFamily: clideMonoFamily, fontSize: clideFontSmall, color: t.globalForeground),
const SizedBox(height: 8),
ClideText('Only allow this if you trust where the link came from.', fontSize: clideFontMeta, color: t.statusWarning),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'Cancel', onPressed: () => onResolve(false)),
const SizedBox(width: 8),
ClideButton(label: 'Open', variant: ClideButtonVariant.primary, onPressed: () => onResolve(true)),
],
),
],
),
);
}
}
+2
View File
@@ -7,6 +7,7 @@ import 'package:clide/builtin/claude/claude.dart';
import 'package:clide/builtin/claude_control/claude_control.dart';
import 'package:clide/builtin/cli_install/cli_install.dart';
import 'package:clide/builtin/decisions/decisions.dart';
import 'package:clide/builtin/deeplink/deeplink.dart';
import 'package:clide/builtin/default_layout/default_layout.dart';
import 'package:clide/builtin/diff/diff.dart';
import 'package:clide/builtin/editor/editor.dart';
@@ -371,6 +372,7 @@ Future<void> main() async {
..register(GitExtension())
..register(PqlExtension())
..register(ProblemsExtension())
..register(DeepLinkExtension())
// Workspace
..register(ClaudeExtension())
..register(TerminalExtension())
+8 -31
View File
@@ -104,37 +104,14 @@ ArgvParseResult parseArgv(List<String> argv, {required String requestId}) {
));
}
/// Translate a `clide://` deep link into an [IpcRequest] (T-56). Only the
/// `open` action is defined today: `clide://open?path=<abs|repo-rel>&line=<n>`
/// maps to `editor.open` (which jumps the selection to the 1-based line). The
/// path is passed through verbatim — `editor.open` resolves it against the
/// workspace and applies the usual `files.read` allow-list (D-80).
ArgvParseResult _deepLinkToRequest(String url, String requestId) {
final uri = Uri.tryParse(url);
if (uri == null || uri.scheme != 'clide') {
return ArgvError(_err(requestId, 'malformed clide:// link: $url'));
}
switch (uri.host) {
case 'open':
final path = uri.queryParameters['path'];
if (path == null || path.isEmpty) {
return ArgvError(_err(requestId, 'clide://open requires a ?path='));
}
final positional = <String>[path];
final lineRaw = uri.queryParameters['line'];
if (lineRaw != null && lineRaw.isNotEmpty) {
final line = int.tryParse(lineRaw);
if (line == null || line < 1) {
return ArgvError(_err(requestId, 'clide://open: line must be a positive integer, got "$lineRaw"'));
}
positional.add('$line');
}
// Mirror the CLI's positional form so the editor.open schema maps them.
return ArgvParsed(IpcRequest(id: requestId, cmd: 'editor.open', args: {'positional': positional}));
default:
return ArgvError(_err(requestId, 'unknown clide:// action "${uri.host}" (expected: open)'));
}
}
/// Route a `clide://` deep link to the `deeplink.invoke` command (T-56). A
/// clide:// URL is an UNTRUSTED external vector — any webpage can fire one — so
/// it is NOT translated into a command here. The raw URL is handed to the
/// deeplink handler, which validates it against a paranoid (default-deny)
/// allowlist and prompts the user before doing anything (D-90).
ArgvParseResult _deepLinkToRequest(String url, String requestId) => ArgvParsed(IpcRequest(id: requestId, cmd: 'deeplink.invoke', args: {
'positional': [url]
}));
// -- internals --------------------------------------------------------------