Skip to content

feat(web): trusted_domains allowlist skips the terminal link-open confirm - #1758

Open
asheshgoplani wants to merge 1 commit into
mainfrom
feat/1682-web-trusted-domains
Open

feat(web): trusted_domains allowlist skips the terminal link-open confirm#1758
asheshgoplani wants to merge 1 commit into
mainfrom
feat/1682-web-trusted-domains

Conversation

@asheshgoplani

Copy link
Copy Markdown
Owner

What problem does this solve?

Closes #1682. Clicking a link in the web terminal always fired xterm.js's built-in OSC-8 handler, which confirms unconditionally: "Do you want to navigate to … WARNING: This link could potentially be dangerous". For anyone working against internal tooling (self-hosted GitLab, Gerrit, CI) every merge-request and build link pays that prompt, several times a minute during review work, for links they already trust.

This adds the [web].trusted_domains allowlist the issue asked for, plus the explicit confirm_link_open toggle:

[web]
trusted_domains = [
  "gitlab.mycorp.example",
  "gerrit.mycorp.example",
  "*.ci.mycorp.example",   # subdomains of ci.mycorp.example
]
confirm_link_open = true    # default; false accepts the risk globally

A link whose host matches an entry opens directly. Everything else still confirms, so the safety net stays in place for arbitrary links — the allowlist form the issue thread picked as the default recommendation.

Why this change

The confirm is not agent-deck code: it lives in the vendored static/vendor/xterm.mjs OscLinkProvider, whose default activate callback is used whenever the terminal has no linkHandler option. So the single link-open call site is the new Terminal({…}) in static/app/TerminalPanel.js: supplying linkHandler replaces the built-in handler outright, with no patching of the vendored bundle.

The policy itself lives in a new static/app/terminalLinks.js and is deliberately conservative, because an allowlist is a security control:

  • Host-only matching, case-insensitive, port- and path-independent. Exact match by default; *.base matches strictly deeper subdomains of base and never the bare base itself.
  • Normalized once in Go (session.NormalizeTrustedDomains) so a pasted URL, a host:port, userinfo, or a trailing dot all reduce to the same comparable host. Unusable entries (*, *., *.example, git.*.corp.example, blanks) are dropped, never widened — *.example would otherwise allow every single-label host on the network. The JS mirrors the same normalizer so a hand-edited config and an API-served list behave identically.
  • Only http/https ever auto-open. file://gitlab.corp.example/… and javascript: never match, whatever host they claim.
  • The open still mirrors xterm exactly: blank window, opener cleared, then navigate; a blocked popup refuses to navigate rather than hand the target a live opener.

Config plumbing follows the existing [web].mutations_enabled path — config → web.ConfigGET /api/settings → signals hydrated by AppShell.js → read at click time, so changing settings needs no terminal rebuild. One deliberate asymmetry: web.Config.ConfirmLinkOpen is a *bool where nil means confirm. A plain bool would let any web.Config{…} built without the field (tests, the e2e fixture, a future caller) silently disable the prompt for every host; the pointer makes the safe value the zero value. GET /api/settings always serializes trustedDomains as an array, never null.

User impact

  • Default behavior is unchanged: with no [web] config, every link still confirms.
  • Opt in per host with [web].trusted_domains; those links now open on the first click.
  • [web].confirm_link_open = false drops the prompt entirely for users who accept the risk (documented as the less-preferred option).
  • The web Settings drawer gained two read-only rows (trusted domains, link confirm) so the active policy is visible without reading config.toml.
  • New [web] section in skills/agent-deck/references/config-reference.md documenting all three keys (mutations_enabled was previously undocumented there too).

Evidence

Playwright e2e (the web gate), tests/web/e2e/trusted-domains.spec.js — boots dedicated web-fixture instances on ephemeral ports (same pattern as read-only-mode.spec.js), one with -trusted-domains gitlab.corp.example,*.ci.corp.example and one with -confirm-link-open=false, then drives the production handler in a real browser against a real server after real /api/settings hydration:

Running 8 tests using 1 worker
  ✓ 1 … GET /api/settings serves the allowlist and confirmLinkOpen:true (19ms)
  ✓ 2 … the settings drawer shows the hydrated policy (841ms)
  ✓ 3 … an ALLOWLISTED link opens with no confirmation (369ms)
  ✓ 4 … a subdomain under a *. entry opens with no confirmation (367ms)
  ✓ 5 … a NON-allowlisted link still confirms before opening (368ms)
  ✓ 6 … a lookalike host that merely ends with a trusted name still confirms (366ms)
  ✓ 7 … GET /api/settings reports confirmLinkOpen:false with an empty allowlist (5ms)
  ✓ 8 … every link opens without a confirmation, allowlist or not (354ms)
  8 passed (34.1s)

The spec explains in-file why it invokes the activate callback xterm itself would call rather than clicking a rendered link cell: an OSC-8 hyperlink only exists in the terminal buffer once a live tmux pane emits one, and the in-memory fixture has no pane. It imports the same module instance the app loaded (identical /static/app/terminalLinks.js URL → same module record, same hydrated signals), so the decision path under test is the real one.

Full tests/web suite (npm run test:unit + npm run test:e2e), sandboxed HOME/XDG:

Test Files  9 passed (9)      Tests  128 passed (128)     # vitest, incl. 40 new
587 passed, 5 failed (4.4m)                              # playwright

The 5 failures are all toHaveScreenshot baseline mismatches (visual-baselines home light/dark, keyboard-parity shortcuts overlay). They reproduce identically on a pristine origin/main checkout on this machine (verified with the change stashed: same 4 visual-baseline failures, 5 passed) — the committed baselines are generated on Linux CI and this run was macOS. Nothing this PR touches is rendered on the home screen or the shortcuts overlay.

Go: go build ./... and go vet ./... clean. Go tests were not run on this host — this machine's $HOME is a live ~/.agent-deck data directory and several packages are not isolated from the real home (the failure mode documented in the repo CLAUDE.md). The new Go coverage is left to CI's sandboxed runners:

  • internal/session/userconfig_web_test.go — defaults when the keys are absent, reading + normalizing the list, confirm_link_open default/explicit, and a 17-case table over NormalizeTrustedDomains (case folding, scheme/path/port/userinfo/trailing-dot stripping, IPv6 literal, dedupe, order preservation, and every entry shape that must be dropped).
  • internal/web/handlers_settings_test.go — the /api/settings contract, including that a Config with ConfirmLinkOpen unset still reports confirmLinkOpen: true.
  • internal/web/issue1682_link_policy_test.go — source pin on the TerminalPanel.js wiring, in the same spirit as TestWebBundle_ChartNotInInitialPayload_RegressionFor1022. Neither behavioral suite can observe whether xterm is actually handed the handler (no OSC-8 link without a live pane; xterm exposes no back-reference from its DOM element to the Terminal instance), and deleting that one option would silently restore the confirm on every link.

Bypass cases pinned in tests/web/unit/terminalLinks.test.js (40 cases): notgitlab.corp.example, gitlab.corp.example.evil.test, evilci.corp.example vs *.ci.corp.example, ci.corp.example against its own *. entry, https://gitlab.corp.example@evil.example/, file:// and javascript: with a trusted host, unparseable input, empty/missing list, declined confirm, blocked popup, and the live-signal re-read.

AI disclosure

  • Human-written
  • AI-assisted (I directed and reviewed it)
  • AI-authored (a model wrote most of it)

Model(s), if AI helped: claude-opus-4-x

What actually bothered you

From the issue, and it matches the maintainer's own review workflow: every merge-request link in a self-hosted GitLab / Gerrit setup fires the same "this could be dangerous" dialog, so reviewing a stack of MRs from the agent-deck web terminal costs several extra clicks a minute on links that are known-safe internal tooling. The friction is bad enough that the realistic alternative is switching the confirm off wholesale, which is exactly the outcome an allowlist avoids.

Note for @amh1k, who offered to take this on: apologies for the overlap — this was picked up in the same sweep that reopened the issue and I did not want it to sit unlanded a second time. Genuinely happy to hand over follow-ups here (a TUI-side equivalent, or a web toggle that writes the config), and review comments on this diff are very welcome.

Checklist

  • Targeted diff: one problem, no unrelated changes
  • Tests added or updated for new behavior
  • Test suite passes sandboxed: HOME=$(mktemp -d) XDG_CONFIG_HOME= XDG_DATA_HOME= XDG_CACHE_HOME= go test ./...not run on this host (its $HOME is a live agent-deck data dir; see Evidence). go build + go vet clean; Go tests verified by CI.
  • If this touches a hot path: n/a — click-time only, no change to list/status/session output/startup/tmux
  • CHANGELOG.md untouched
  • AI-assisted? Disclosed above, with validation evidence
  • "Allow edits from maintainers" is enabled

…firm

Clicking a link in the web terminal always fired xterm's built-in OSC-8
handler, which confirms unconditionally ("This link could potentially be
dangerous"). Against internal tooling — self-hosted GitLab, Gerrit, CI —
every merge-request and build link pays that prompt, several times a minute
during review work, for links the user already trusts.

TerminalPanel now installs its own xterm `linkHandler` (static/app/
terminalLinks.js) that consults two new `[web]` keys:

    [web]
    trusted_domains   = ["gitlab.corp.example", "*.ci.corp.example"]
    confirm_link_open = true   # default; false accepts the risk globally

A link whose host matches an entry opens directly; everything else keeps the
confirm, so the safety net stays in place for arbitrary links. Matching is on
host only: case-insensitive, port- and path-independent, exact by default,
with `*.base` matching strictly deeper subdomains of `base` (never the bare
base itself). Entries are normalized once in Go
(session.NormalizeTrustedDomains) so a pasted URL, a host:port, or a trailing
dot all reduce to the same comparable host, and unusable entries (`*`,
`*.example`, blanks) are dropped rather than widened. Only http/https links
are ever auto-opened, and the open itself still mirrors xterm's: blank
window, `opener` cleared, then navigate.

Plumbing follows the existing `[web].mutations_enabled` path: config →
web.Config → GET /api/settings → signals hydrated by AppShell → read at
click time, so a settings change needs no terminal rebuild. web.Config takes
`ConfirmLinkOpen *bool` where nil means "confirm", so a Config that omits the
field can never silently disable the prompt. The settings drawer shows both
active values.

Tests: 40 vitest cases on the matcher and the open path, including the
bypass attempts an allowlist has to survive (suffix lookalikes, wildcard
base, host smuggled into userinfo, non-http schemes, popup blocked); a
Playwright spec that boots dedicated web-fixture instances and proves
allowlisted-vs-not and the global opt-out in a real browser against a real
server; Go coverage for config normalization, the /api/settings contract
(including the safe default), and a source pin on the TerminalPanel wiring,
which neither behavioral suite can observe because an OSC-8 link needs a live
tmux pane.

Closes #1682

Committed by Ashesh Goplani
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@asheshgoplani, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ba7a853-1f47-4bfc-b60e-638b7aa66aa1

📥 Commits

Reviewing files that changed from the base of the PR and between 1a4711d and 8484cbf.

⛔ Files ignored due to path filters (1)
  • skills/agent-deck/references/config-reference.md is excluded by !**/*.md
📒 Files selected for processing (16)
  • cmd/agent-deck/web_cmd.go
  • internal/session/userconfig.go
  • internal/session/userconfig_web_test.go
  • internal/web/api_types.go
  • internal/web/handlers_settings.go
  • internal/web/handlers_settings_test.go
  • internal/web/issue1682_link_policy_test.go
  • internal/web/server.go
  • internal/web/static/app/AppShell.js
  • internal/web/static/app/SettingsPanel.js
  • internal/web/static/app/TerminalPanel.js
  • internal/web/static/app/state.js
  • internal/web/static/app/terminalLinks.js
  • tests/web/e2e/trusted-domains.spec.js
  • tests/web/fixtures/cmd/web-fixture/main.go
  • tests/web/unit/terminalLinks.test.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1682-web-trusted-domains

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added intake:clean PR/issue passed the intake contract ai-assisted AI-assisted contribution labels Jul 26, 2026
@github-actions

Copy link
Copy Markdown

👋 Thanks for the contribution — intake looks complete.

Your PR body carries everything the maintainer's validation pipeline reads first: the problem, the reasoning, the human intent behind it, and an AI-disclosure. It will be applied, built, and tested against main, and you'll get a structured result within about a day. Merges are always human.

gate marker read: ai=assisted model=claude-opus-4-x intent=yes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted AI-assisted contribution intake:clean PR/issue passed the intake contract

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Web UI: allowlist trusted domains to skip link-open confirmation

1 participant