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
+10 -29
View File
@@ -150,37 +150,18 @@ void main() {
});
});
group('parseArgv — clide:// deep links (T-56)', () {
test('clide://open?path= maps to editor.open', () {
final req = _expectOk(parseArgv(['clide://open?path=/repo/x.md'], requestId: '1'));
expect(req.cmd, 'editor.open');
expect(req.args['positional'], ['/repo/x.md']);
group('parseArgv — clide:// deep links route to the gated handler (T-56)', () {
test('a clide:// URL is handed verbatim to deeplink.invoke (not translated)', () {
// Validation + the user prompt happen in the handler (D-90), not here.
final req = _expectOk(parseArgv(['clide://open?path=/repo/x.md&line=42'], requestId: '1'));
expect(req.cmd, 'deeplink.invoke');
expect(req.args['positional'], ['clide://open?path=/repo/x.md&line=42']);
});
test('a &line= becomes the second positional', () {
final req = _expectOk(parseArgv(['clide://open?path=/repo/x.md&line=42'], requestId: '2'));
expect(req.cmd, 'editor.open');
expect(req.args['positional'], ['/repo/x.md', '42']);
});
test('an encoded path is decoded', () {
final req = _expectOk(parseArgv(['clide://open?path=/a%20b/c.dart'], requestId: '3'));
expect(req.args['positional'], ['/a b/c.dart']);
});
test('missing path errors', () {
final err = _expectErr(parseArgv(['clide://open'], requestId: '4'));
expect(err.error?.message, contains('requires a ?path'));
});
test('a non-positive or non-numeric line errors', () {
expect(_expectErr(parseArgv(['clide://open?path=/x&line=0'], requestId: '5')).error?.message, contains('positive integer'));
expect(_expectErr(parseArgv(['clide://open?path=/x&line=abc'], requestId: '6')).error?.message, contains('positive integer'));
});
test('an unknown action errors', () {
final err = _expectErr(parseArgv(['clide://frobnicate?x=1'], requestId: '7'));
expect(err.error?.message, contains('unknown clide:// action'));
test('even an unknown action is passed through (the handler rejects it)', () {
final req = _expectOk(parseArgv(['clide://frobnicate?x=1'], requestId: '2'));
expect(req.cmd, 'deeplink.invoke');
expect(req.args['positional'], ['clide://frobnicate?x=1']);
});
});
}