From e5189206f9a405fd580a16fe82d556958f1c926e Mon Sep 17 00:00:00 2001 From: Arthur Breitman Date: Wed, 17 Jun 2026 13:22:38 +0000 Subject: [PATCH 01/15] feat(web): Version Control nav group + Connections/Policies sub-tab parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that go together: 1. Connections/Policies sidebars now share the same top-level structure: Google, Cloud, LLM API, HTTP Proxy, MCP Proxy, Version Control, Slack. Previously Connections collapsed HTTP+MCP into one "Proxy" tab while Policies kept them split — they're now consistent. 2. New "Version Control" sub-group in both sidebars, parent links to ?type=version_control / ?scope=version_control, expandable children link directly to GitHub and GitLab. The group is collapsible like the existing Google/Cloud sub-groups in Policies. 3. handleConnections + handlePolicies recognise the synthetic "version_control" filter value and include both github and gitlab connectors/policies in the result. The catalog already groups GitHub + GitLab cards under a "Version Control" h3 header (their ConnectorMeta.Category), so the in-page section was always present — this just makes the sidebar match. Also rewrites the GitHub card's PAT-vs-App explanation panel: the inner grid-cols-2 layout squeezed both halves when the parent card sat in a 3-column grid at lg. Each option now stacks vertically as a single-paragraph description so it reads cleanly in the narrow card. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/web/server.go | 16 ++++++++++- internal/web/templates/connections.html | 36 +++++++++---------------- internal/web/templates/nav.html | 32 ++++++++++++++++++++-- 3 files changed, 57 insertions(+), 27 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index a7c91d0..8f97fc8 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -554,6 +554,11 @@ func (s *Server) handleConnections(w http.ResponseWriter, r *http.Request) { if cat == "http_proxy" || c.ConnectorType == "mcp_proxy" { conns = append(conns, c) } + } else if connType == "version_control" { + // "version_control" tab groups github + gitlab. + if cat == "github" || cat == "gitlab" { + conns = append(conns, c) + } } else if cat == connType { conns = append(conns, c) } @@ -573,6 +578,10 @@ func (s *Server) handleConnections(w http.ResponseWriter, r *http.Request) { if connType == "proxy" { match = m.Type == "http_proxy" || m.Type == "mcp_proxy" } + // "version_control" tab shows both github and gitlab connectors + if connType == "version_control" { + match = m.Type == "github" || m.Type == "gitlab" + } if match { filtered[category] = append(filtered[category], m) } @@ -1434,10 +1443,15 @@ func (s *Server) handlePolicies(w http.ResponseWriter, r *http.Request) { // Filter policies by scope. A policy's scope is stored in its config. // Policies without a scope (legacy/presets) show under all tabs. + // The synthetic "version_control" scope groups github + gitlab policies. var pols []policies.Policy for _, p := range allPols { pScope, _ := p.PolicyConfig["scope"].(string) - if scope == "" || pScope == "" || pScope == scope { + match := scope == "" || pScope == "" || pScope == scope + if scope == "version_control" && (pScope == "github" || pScope == "gitlab") { + match = true + } + if match { pols = append(pols, p) } } diff --git a/internal/web/templates/connections.html b/internal/web/templates/connections.html index ff0d56b..b7863ba 100644 --- a/internal/web/templates/connections.html +++ b/internal/web/templates/connections.html @@ -201,34 +201,22 @@

{{.Name}}

{{else if eq .Type "github"}} -
+

Two ways to connect. Pick what matches your access pattern.

-
-
-
- Fine-grained PAT - Recommended -
-
    -
  • Fastest setup — paste a token, done.
  • -
  • Scoped to specific repos under one owner.
  • -
  • Acts as you (or a service-account user).
  • -
  • Tokens expire — you'll re-paste later.
  • -
+
+
+ Fine-grained PAT + Recommended
-
-
- GitHub App install -
-
    -
  • Org-wide install, finer-grained scopes per repo.
  • -
  • Ephemeral 1-hour tokens — Sieve refreshes automatically.
  • -
  • Acts as the App (not a real user), better for audit.
  • -
  • Requires your Sieve host to be reachable from GitHub.
  • -
+

Paste a token; done. Scoped to specific repos under one owner. Acts as you (or a service-account user). Tokens expire and need re-pasting.

+
+
+
+ GitHub App install
+

Org-wide install with finer-grained per-repo scopes. Ephemeral 1-hour tokens — Sieve refreshes automatically. Acts as the App (better audit). Requires your Sieve host to be reachable from GitHub for the callback.

-

Use a PAT for a single repo or personal/dev access; install the App when multiple repos in an org need long-lived, auto-rotating access.

+

Use a PAT for a single repo or personal/dev access; install the App when multiple repos in an org need long-lived, auto-rotating access.

diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html index 33ecad5..1444d1c 100644 --- a/internal/web/templates/nav.html +++ b/internal/web/templates/nav.html @@ -28,8 +28,19 @@
  • Google
  • Cloud
  • LLM API
  • -
  • Proxy (HTTP & MCP)
  • -
  • GitHub
  • +
  • HTTP Proxy
  • +
  • MCP Proxy
  • + +
  • + + +
  • Slack
  • Coming Soon @@ -114,6 +125,17 @@
  • LLM API
  • HTTP Proxy
  • MCP Proxy
  • + +
  • + + +
  • Slack
  • @@ -178,6 +200,9 @@ var active = "{{.Active}}"; if (active.startsWith("connections-")) { toggleSub('conn-sub'); + if (active === "connections-github" || active === "connections-gitlab" || active === "connections-version_control") { + toggleSub('conn-vcs-sub'); + } } if (active.startsWith("policies-")) { toggleSub('policy-sub'); @@ -190,6 +215,9 @@ toggleSub('aws-sub'); } } + if (active === "policies-github" || active === "policies-gitlab" || active === "policies-version_control") { + toggleSub('policy-vcs-sub'); + } } })(); From 67e4210c5dca703a403446651811413f1a2f1b61 Mon Sep 17 00:00:00 2001 From: Arthur Breitman Date: Wed, 17 Jun 2026 13:25:14 +0000 Subject: [PATCH 02/15] feat(web): Ctrl/Cmd+K command palette for jump-to-page navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fuzzy-search modal triggered by Ctrl+K (or ⌘+K on macOS) that lists every top-level page and every Connections/Policies sub-tab. A sidebar chip shows the keybinding ("⌘K" on Mac, "Ctrl+K" elsewhere). UX details: - Scoring: exact-prefix > substring-in-label > substring-in-keywords > subsequence-fuzzy. Each item carries an optional keywords field for common aliases (e.g. "Connections / Google" matches "gmail drive"). - ↑/↓ navigate; Enter opens; Esc / backdrop-click closes; Ctrl+K toggles. - innerHTML rendering uses an inline HTML-escape on every interpolated value (defense-in-depth even though items come from a static list); annotated with `// xss-safe:` so the templates-XSS lint passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/web/templates/nav.html | 203 ++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html index 1444d1c..b15654e 100644 --- a/internal/web/templates/nav.html +++ b/internal/web/templates/nav.html @@ -9,6 +9,13 @@
    Sieve +
    Sieve -
    + {{else if eq .Scope "github"}} +
    +

    Read

    +
    + + + + + + +
    +
    +
    +

    Write

    +
    + + + + +
    +
    +
    +

    Escape hatch (any GitHub REST path)

    +
    + +
    +
    + {{else if eq .Scope "gitlab"}} +
    +

    Read

    +
    + + + + + + +
    +
    +
    +

    Write

    +
    + + + + +
    +
    +
    +

    Escape hatch (any GitLab REST path)

    +
    + +
    +
    + {{else if eq .Scope "version_control"}} +
    +

    Pick a connector to write rules

    +

    “Version Control” is a browse filter that lists GitHub and GitLab policies together. Each connector exposes a different operation namespace, so rule authoring happens per-connector:

    + +
    {{else}}

    No operations picker for scope “{{.Scope}}”

    From e73761f32a498e6d38836de115f45ed13ef1128c Mon Sep 17 00:00:00 2001 From: Arthur Breitman Date: Thu, 18 Jun 2026 08:44:46 +0000 Subject: [PATCH 05/15] fix(pr24): consolidate test GET helper, address Copilot round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single substantive change: the two new version_control tests each had their own copy-paste GET helper. Promoted the canonical "GET with operator session cookie" helper to server_status_test.go (matching the existing authedPost), and both new tests now call it directly. Avoids the drift Copilot flagged. The chip-button type="button" comment Copilot reposted is already in place since 99448cf — it's reviewing stale state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/connections_version_control_test.go | 23 +++---------------- internal/web/policies_version_control_test.go | 20 +++------------- internal/web/server_status_test.go | 16 +++++++++++++ 3 files changed, 22 insertions(+), 37 deletions(-) diff --git a/internal/web/connections_version_control_test.go b/internal/web/connections_version_control_test.go index 1162999..dfdaff3 100644 --- a/internal/web/connections_version_control_test.go +++ b/internal/web/connections_version_control_test.go @@ -8,7 +8,6 @@ package web_test import ( "net/http" - "net/http/httptest" "strings" "testing" @@ -17,22 +16,6 @@ import ( gitlabconn "github.com/trilitech/Sieve/internal/connectors/gitlab" ) -// getConnectionsPage is a thin wrapper around an authenticated GET on -// the /connections page (and its filtered variants) so each subtest -// reads as query-and-assert. -func getConnectionsPage(t *testing.T, handler http.Handler, env interface { - SessionCookie() *http.Cookie -}, path string) *httptest.ResponseRecorder { - t.Helper() - req := httptest.NewRequest(http.MethodGet, path, nil) - if c := env.SessionCookie(); c != nil { - req.AddCookie(c) - } - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - return rec -} - // TestConnectionsPage_VersionControlTab asserts: // - /connections?type=version_control surfaces both the github and // gitlab connector catalog cards. @@ -73,7 +56,7 @@ func TestConnectionsPage_VersionControlTab(t *testing.T) { } t.Run("version_control tab shows both catalog cards + both connections", func(t *testing.T) { - rec := getConnectionsPage(t, handler, env, "/connections?type=version_control") + rec := getRequest(handler, env,"/connections?type=version_control") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } @@ -102,7 +85,7 @@ func TestConnectionsPage_VersionControlTab(t *testing.T) { }) t.Run("github tab excludes gitlab", func(t *testing.T) { - rec := getConnectionsPage(t, handler, env, "/connections?type=github") + rec := getRequest(handler, env,"/connections?type=github") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } @@ -116,7 +99,7 @@ func TestConnectionsPage_VersionControlTab(t *testing.T) { }) t.Run("gitlab tab excludes github", func(t *testing.T) { - rec := getConnectionsPage(t, handler, env, "/connections?type=gitlab") + rec := getRequest(handler, env,"/connections?type=gitlab") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } diff --git a/internal/web/policies_version_control_test.go b/internal/web/policies_version_control_test.go index dbd2a69..f8f7c72 100644 --- a/internal/web/policies_version_control_test.go +++ b/internal/web/policies_version_control_test.go @@ -8,26 +8,12 @@ package web_test import ( "net/http" - "net/http/httptest" "strings" "testing" "github.com/trilitech/Sieve/internal/testing/testenv" ) -// getPoliciesPage is a thin wrapper around an authenticated GET so the -// test reads as a query-and-assert. -func getPoliciesPage(t *testing.T, handler http.Handler, env *testenv.Env, path string) *httptest.ResponseRecorder { - t.Helper() - req := httptest.NewRequest(http.MethodGet, path, nil) - if c := env.SessionCookie(); c != nil { - req.AddCookie(c) - } - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - return rec -} - // TestPoliciesPage_VersionControlScope asserts: // - ?scope=version_control includes policies tagged github and gitlab. // - That synthetic scope does NOT include policies for unrelated @@ -44,7 +30,7 @@ func TestPoliciesPage_VersionControlScope(t *testing.T) { mustSeedPolicy(t, env, "legacy-no-scope", "") // empty scope — shows under all tabs t.Run("version_control includes github + gitlab, excludes slack", func(t *testing.T) { - rec := getPoliciesPage(t, handler, env, "/policies?scope=version_control") + rec := getRequest(handler, env,"/policies?scope=version_control") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } @@ -60,7 +46,7 @@ func TestPoliciesPage_VersionControlScope(t *testing.T) { }) t.Run("github scope excludes gitlab", func(t *testing.T) { - rec := getPoliciesPage(t, handler, env, "/policies?scope=github") + rec := getRequest(handler, env,"/policies?scope=github") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } @@ -74,7 +60,7 @@ func TestPoliciesPage_VersionControlScope(t *testing.T) { }) t.Run("slack scope excludes both vcs policies", func(t *testing.T) { - rec := getPoliciesPage(t, handler, env, "/policies?scope=slack") + rec := getRequest(handler, env,"/policies?scope=slack") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } diff --git a/internal/web/server_status_test.go b/internal/web/server_status_test.go index 5aecabb..a3e9a57 100644 --- a/internal/web/server_status_test.go +++ b/internal/web/server_status_test.go @@ -48,6 +48,22 @@ func authedPost(t *testing.T, env *testenv.Env, path string) (*http.Request, *ht return req, httptest.NewRecorder() } +// getRequest performs an authenticated GET against an admin handler and +// returns the recorder. Shared by the web_test package's GET-and-assert +// tests so a single canonical "GET with session cookie" path lives here +// instead of being re-implemented in each test file. (The web/internal +// slack_test.go has a same-named helper for the internal package; this +// is its external-package counterpart.) +func getRequest(handler http.Handler, env *testenv.Env, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + if c := env.SessionCookie(); c != nil { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + // TestServer_DisableConnection_RejectsAgentToken verifies that an // agent bearer token cannot disable a connection through the admin UI. // The requireOperatorSession middleware inspects the Authorization From 56ade39fe1628157359bb04108082f5a83b73a02 Mon Sep 17 00:00:00 2001 From: Arthur Breitman Date: Thu, 18 Jun 2026 08:50:00 +0000 Subject: [PATCH 06/15] fix(pr24): address Copilot round 4 Three threads from Copilot's review of 99448cf: 1. policies.html / handlePolicyCreate: the create-policy form on the ?scope=version_control tab would have persisted scope="version_control" into the saved policy config. The synthetic filter only pulls github + gitlab + unscoped policies, so a policy stamped with scope=version_control would orphan from both the github and gitlab tabs while showing up only under the synthetic group. Template: hide the create form on this scope and show a "browse-only view" card with links to ?scope=github / ?scope=gitlab. Server: reject POST /policies/create when policy_config.scope is "version_control" (defence-in-depth against a hand-crafted POST or stale browser tab). 2. nav.html cmdk overlay: added role="dialog", aria-modal="true", aria-labelledby pointing at a screen-reader-only

    title, plus role="listbox" on the results
      and aria-controls on the input. The chevron SVG inside the input gained aria-hidden="true" so it doesn't confuse screen readers. 3. nav.html cmdk overlay: closing the palette didn't restore focus to the element that triggered it (focus would land at ). openCmdK now captures document.activeElement; closeCmdK restores it, guarding against the captured node being removed from the DOM in the interim. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/web/server.go | 15 +++++++++++++++ internal/web/templates/nav.html | 27 ++++++++++++++++++++++----- internal/web/templates/policies.html | 16 +++++++++++++++- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 8f97fc8..31eca88 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1489,6 +1489,21 @@ func (s *Server) handlePolicyCreate(w http.ResponseWriter, r *http.Request) { policyConfig = make(map[string]any) } + // "version_control" is a synthetic browse-only scope used in the + // sidebar grouping; it isn't a real connector scope. The + // /policies?scope=version_control filter pulls policies whose + // pScope is github or gitlab (plus unscoped legacies). Persisting + // scope=version_control would orphan the policy from both the + // github and gitlab tabs while only matching the version_control + // tab — almost certainly an accident, so refuse it loudly. The + // policies.html template hides the create form under this scope; + // the server-side reject is defence-in-depth against a hand-crafted + // POST or an out-of-date browser tab. + if sc, _ := policyConfig["scope"].(string); sc == "version_control" { + http.Error(w, "scope \"version_control\" is a browse-only filter; pick github or gitlab for a real policy", http.StatusBadRequest) + return + } + // Validate the policy rules against known operations. if errs := s.validatePolicyRules(policyConfig); len(errs) > 0 { http.Error(w, "Policy validation errors:\n"+strings.Join(errs, "\n"), http.StatusBadRequest) diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html index 939b3c2..9d3ee5d 100644 --- a/internal/web/templates/nav.html +++ b/internal/web/templates/nav.html @@ -237,15 +237,18 @@ })(); - +