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
+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 --------------------------------------------------------------