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:
@@ -0,0 +1,5 @@
|
||||
/// Built-in `clide://` deep-link handler (T-56, D-90).
|
||||
library;
|
||||
|
||||
export 'src/deep_link.dart';
|
||||
export 'src/extension.dart';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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 --------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user