Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/added-10676-public-knowledge-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add owner-only public Knowledge MCP sharing controls to the Knowledge header and Settings, with persisted tag scope, enable confirmation, read-only viewer status, and audit logging (#10676).
4 changes: 2 additions & 2 deletions src/docs/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ This reference is compiled by hand from the Go source under `src/`, the deployme
| `HIVE_METRICS_ENABLED` | No | disabled | Registers Prometheus `/metrics` when set to `1`, `true`, `yes`, or `on`. Requires `HIVE_METRICS_TOKEN` — enabled-but-tokenless returns 403 ([#3804](https://github.com/hivecommons/hive/pull/3804)). |
| `HIVE_METRICS_TOKEN` | Yes when metrics enabled | none | Bearer token read by `pkg/dashboard/metrics_prometheus.go` for `/metrics` (`Authorization: Bearer ...`; Prometheus `bearer_token`). `/metrics` bypasses dashboard session auth, so this token is its only guard; enabled-but-tokenless fails closed and the cost/agent series are never served without it. |
| `HIVE_METRICS_FILE` | No | `/var/run/hive-metrics/contribute.json` | Contributor metrics JSON file override. |
| `HIVE_PUBLIC_KNOWLEDGE` | No | disabled | Owner switch for the anonymous, **read-only** MCP knowledge endpoint `POST /mcp/knowledge` (`1`, `true`, `yes`, or `on` — same spelling as `HIVE_METRICS_ENABLED`). Off → 404 even for authenticated callers. Read on each request so it can be closed without a pod roll. Only operational fact types (pattern, gotcha, regression, test_scaffold, integration, coverage_rule, general) are served; ideation/governance types, sources, and usage data never leave the hive. See [public-knowledge-mcp.md](public-knowledge-mcp.md) ([#10615](https://github.com/hivecommons/hive/issues/10615)). |
| `HIVE_PUBLIC_KNOWLEDGE_TAGS` | No | none (all public-type facts) | Comma-separated tag allow-list that further narrows what `/mcp/knowledge` serves to facts carrying at least one listed tag (case-insensitive). |
| `HIVE_PUBLIC_KNOWLEDGE` | No | disabled | Fallback owner switch (overridden by explicit `knowledge.public` settings in the dashboard) for the anonymous, **read-only** MCP knowledge endpoint `POST /mcp/knowledge` (`1`, `true`, `yes`, or `on` — same spelling as `HIVE_METRICS_ENABLED`). Off → 404 even for authenticated callers. Read on each request so it can be closed without a pod roll. Only operational fact types (pattern, gotcha, regression, test_scaffold, integration, coverage_rule, general) are served; ideation/governance types, sources, and usage data never leave the hive. See [public-knowledge-mcp.md](public-knowledge-mcp.md) ([#10615](https://github.com/hivecommons/hive/issues/10615)). |
| `HIVE_PUBLIC_KNOWLEDGE_TAGS` | No | none (all public-type facts) | Fallback comma-separated tag allow-list (overridden by `knowledge.public.tags` when the config block is present) that further narrows what `/mcp/knowledge` serves to facts carrying at least one listed tag (case-insensitive). |
| `HIVE_COPILOT_INTEGRATION_ID` | No | compiled Copilot integration id | Overrides the integration id used by Copilot model discovery. |
| `HIVE_CONTRIBUTORS_DIR` | No | hub default | Contributor registry directory override. |
| `HIVE_CONTRIBUTE_SKIP_LABELS` | No | `blocked,tracking,epic,discussion,question,needs-decision,needs-triage` | Comma-separated, case-insensitive label patterns for issues that are not contributor work and must never be offered by the relay. Patterns use `path.Match`-style `*` globs; `blocked` is always unioned into the effective set even if omitted. Same setting as `hub.contribute_skip_labels`. |
Expand Down
22 changes: 21 additions & 1 deletion src/docs/public-knowledge-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ motivating case is a distro's troubleshooting assistant reasoning from the
project's own accumulated fixes instead of only what its model was trained on
([#10615](https://github.com/hivecommons/hive/issues/10615)).

## Dashboard control

The Knowledge section header and **Settings → Knowledge sharing** show whether
public MCP access is enabled, its tag scope, and the endpoint URL. Owners can
change the toggle and comma-separated tags, then apply the settings. Enabling
requires an in-app confirmation. Other authenticated viewers see read-only
status and an owner-only explanation. The URL can be copied from either control.

`GET /api/knowledge/public` returns `{enabled, tags, source, endpoint, url}` to
authenticated dashboard viewers. Owner-only `PUT /api/knowledge/public` accepts
`{"enabled": false, "tags": []}` and persists `knowledge.public` through runtime
config persistence, including the Kubernetes dashboard overlay. Changes are
audited. A failed save returns an error and restores the previous runtime value.

An explicit `knowledge.public` config block overrides both environment variables,
including when `enabled` is false or `tags` is empty. Without that block, the
environment controls below remain backward compatible. Settings are checked for
every request, so disabling closes subsequent requests immediately. Requests
already in progress may finish.

## Owner switch

| Variable | Effect |
Expand Down Expand Up @@ -103,7 +123,7 @@ curl -s https://hive.example.org/mcp/knowledge \
On hosted hives the hub's nginx `auth_request` gate waves `/mcp/knowledge`
through (same mechanism as `/api/knowledge/export`, #8294) so an agent with
no browser session is not bounced to the login page. This opens nothing by
itself: the spoke re-checks `HIVE_PUBLIC_KNOWLEDGE` and 404s when it is off.
itself: the spoke re-checks the effective public-sharing setting and 404s when it is off.

## Related

Expand Down
4 changes: 4 additions & 0 deletions src/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5441,6 +5441,10 @@ func loadWithDashboardOverlay(path string, validateOpts ValidateOptions) (*Confi
if err := yaml.Unmarshal([]byte(expandEnvVars(string(data))), &overlay); err != nil {
return cfg, nil // malformed overlay: fall back to seed, don't fail the reload
}
// An explicit public-sharing setting (including false) survives pod restarts.
if overlay.Knowledge.Public != nil {
cfg.Knowledge.Public = overlay.Knowledge.Public
}
// Tombstones live in the dashboard overlay because that is the only agent
// source the dashboard can write. Adopt them BEFORE the fullness guard below
// so a short/empty overlay (one that has no agents yet, or only carries the
Expand Down
25 changes: 16 additions & 9 deletions src/pkg/config/knowledge_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,23 @@ type DocSourceConfigYAML struct {
Layer string `yaml:"layer"`
}

// PublicKnowledgeConfig overrides the environment when explicitly configured.
type PublicKnowledgeConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Tags []string `yaml:"tags" json:"tags"`
}

type KnowledgeConfig struct {
Enabled bool `yaml:"enabled"`
Engine string `yaml:"engine"`
Layers []KnowledgeLayer `yaml:"layers"`
Vaults []VaultConfig `yaml:"vaults"`
GitSources []GitSourceConfigYAML `yaml:"git_sources"`
Documents []DocSourceConfigYAML `yaml:"documents"`
Curator KnowledgeCurator `yaml:"curator"`
Primer KnowledgePrimer `yaml:"primer"`
BeadSynthesizer BeadSynthesizerConfig `yaml:"bead_synthesizer"`
Public *PublicKnowledgeConfig `yaml:"public,omitempty"`
Enabled bool `yaml:"enabled"`
Engine string `yaml:"engine"`
Layers []KnowledgeLayer `yaml:"layers"`
Vaults []VaultConfig `yaml:"vaults"`
GitSources []GitSourceConfigYAML `yaml:"git_sources"`
Documents []DocSourceConfigYAML `yaml:"documents"`
Curator KnowledgeCurator `yaml:"curator"`
Primer KnowledgePrimer `yaml:"primer"`
BeadSynthesizer BeadSynthesizerConfig `yaml:"bead_synthesizer"`
}

// BeadSynthesizerConfig controls automatic synthesis of completed beads into wiki facts.
Expand Down
42 changes: 42 additions & 0 deletions src/pkg/config/public_knowledge_overlay_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package config

import (
"os"
"path/filepath"
"testing"
)

func TestPublicKnowledgeDashboardOverlay(t *testing.T) {
dir := t.TempDir()
seedPath := filepath.Join(dir, "hive.yaml")
seed := "project:\n org: testorg\n repos: [repo1]\ngithub:\n token: test\nagents:\n scanner:\n backend: copilot\n model: claude-sonnet-4-6\nknowledge:\n public:\n enabled: true\n tags: [seed]\n"
if err := os.WriteFile(seedPath, []byte(seed), 0600); err != nil {
t.Fatal(err)
}
original := DashboardOverlayFile
DashboardOverlayFile = filepath.Join(dir, "overlay.yaml")
t.Cleanup(func() { DashboardOverlayFile = original })
t.Setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
for _, tc := range []struct {
name, overlay string
enabled bool
tag string
}{
{"explicit disable", "knowledge:\n public:\n enabled: false\n tags: [public]\n", false, "public"},
{"absent preserves seed", "knowledge:\n enabled: false\n", true, "seed"},
} {
t.Run(tc.name, func(t *testing.T) {
if err := os.WriteFile(DashboardOverlayFile, []byte(tc.overlay), 0600); err != nil {
t.Fatal(err)
}
cfg, err := LoadWithDashboardOverlay(seedPath)
if err != nil {
t.Fatal(err)
}
p := cfg.Knowledge.Public
if p == nil || p.Enabled != tc.enabled || len(p.Tags) != 1 || p.Tags[0] != tc.tag {
t.Fatalf("restored public settings: %+v", p)
}
})
}
}
2 changes: 2 additions & 0 deletions src/pkg/dashboard/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ func (s *Server) RegisterAPI(deps *Dependencies) {
s.mux.HandleFunc("GET /api/config/backends", s.handleBackends)
s.mux.HandleFunc("GET /api/inference/models/{backend}", s.handleInferenceModels)

s.mux.HandleFunc("GET /api/knowledge/public", s.handlePublicKnowledgeSettings)
s.mux.HandleFunc("PUT /api/knowledge/public", s.handlePublicKnowledgeSettingsUpdate)
s.mux.HandleFunc("GET /api/knowledge", s.handleKnowledgeList)
s.mux.HandleFunc("GET /api/knowledge/export", s.handleKnowledgeExport)
s.mux.HandleFunc("GET /api/knowledge/search", s.handleKnowledgeSearch)
Expand Down
81 changes: 81 additions & 0 deletions src/pkg/dashboard/api_knowledge_public.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package dashboard

import (
"fmt"
"net/http"
"strings"

"github.com/hivecommons/hive/pkg/config"
)

type publicKnowledgeSettings struct {
Enabled bool `json:"enabled"`
Tags []string `json:"tags"`
Source string `json:"source"`
Endpoint string `json:"endpoint"`
URL string `json:"url"`
}

func normalizePublicKnowledgeTags(tags []string) []string {
result := make([]string, 0, len(tags))
seen := make(map[string]bool)
for _, tag := range tags {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag != "" && !seen[tag] {
result = append(result, tag)
seen[tag] = true
}
}
return result
}

func (s *Server) publicKnowledgeSettings() publicKnowledgeSettings {
s.publicKnowledgeMu.RLock()
defer s.publicKnowledgeMu.RUnlock()
result := publicKnowledgeSettings{Enabled: publicKnowledgeEnabled(), Tags: normalizePublicKnowledgeTags(publicKnowledgeTags()), Source: "env", Endpoint: publicKnowledgeMCPPath}
if s.deps != nil && s.deps.Config != nil && s.deps.Config.Knowledge.Public != nil {
cfg := s.deps.Config.Knowledge.Public
result.Enabled = cfg.Enabled
result.Tags = normalizePublicKnowledgeTags(cfg.Tags)
result.Source = "config"
}
return result
}

func (s *Server) handlePublicKnowledgeSettings(w http.ResponseWriter, r *http.Request) {
settings := s.publicKnowledgeSettings()
settings.URL = s.oauthPublicOrigin(r) + publicKnowledgeMCPPath
jsonResponse(w, settings)
}

func (s *Server) handlePublicKnowledgeSettingsUpdate(w http.ResponseWriter, r *http.Request) {
if !requireOwnerRole(w, r) {
return
}
var body struct {
Enabled *bool `json:"enabled"`
Tags []string `json:"tags"`
}
if err := decodeBody(r, &body); err != nil || body.Enabled == nil {
jsonError(w, "enabled must be a boolean and tags must be an array of strings", http.StatusBadRequest)
return
}
if s.deps == nil || s.deps.Config == nil || s.deps.Config.SourcePath == "" {
jsonError(w, "runtime config persistence is unavailable", http.StatusServiceUnavailable)
return
}
tags := normalizePublicKnowledgeTags(body.Tags)
s.publicKnowledgeMu.Lock()
previous := s.deps.Config.Knowledge.Public
s.deps.Config.Knowledge.Public = &config.PublicKnowledgeConfig{Enabled: *body.Enabled, Tags: tags}
if err := s.saveConfig(); err != nil {
s.deps.Config.Knowledge.Public = previous
s.publicKnowledgeMu.Unlock()
s.logger.Error("failed to persist public knowledge settings", "error", err)
jsonError(w, "could not persist public knowledge settings", http.StatusInternalServerError)
return
}
s.auditFromRequest(r, "knowledge_public_update", auditDetail("enabled", fmt.Sprint(*body.Enabled), "tags", strings.Join(tags, ",")), "")
s.publicKnowledgeMu.Unlock()
s.handlePublicKnowledgeSettings(w, r)
}
139 changes: 139 additions & 0 deletions src/pkg/dashboard/api_knowledge_public_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package dashboard

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/hivecommons/hive/pkg/config"
"gopkg.in/yaml.v3"
)

func publicSettingsRequest(s *Server, method, body string, owner bool) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/api/knowledge/public", strings.NewReader(body))
if owner {
r.Header.Set("Authorization", "Bearer dashboard-secret")
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}

func TestPublicKnowledgeSettingsPersistenceAndImmediateDisable(t *testing.T) {
t.Setenv(publicKnowledgeEnabledEnv, "1")
t.Setenv(publicKnowledgeTagsEnv, " env-tag ")
s := publicKnowledgeServer(t)
s.deps.Config.SourcePath = filepath.Join(t.TempDir(), "hive.yaml")
s.deps.Config.Dashboard.PublicURL = "https://knowledge.example"
w := publicSettingsRequest(s, "GET", "", true)
var initial publicKnowledgeSettings
if err := json.Unmarshal(w.Body.Bytes(), &initial); err != nil {
t.Fatal(err)
}
if !initial.Enabled || initial.Source != "env" || initial.Tags[0] != "env-tag" || initial.URL != "https://knowledge.example/mcp/knowledge" {
t.Fatalf("unexpected initial settings: %+v", initial)
}

w = publicSettingsRequest(s, "PUT", `{"enabled":false,"tags":[" Linux ","linux",""]}`, true)
if w.Code != http.StatusOK {
t.Fatalf("PUT: %d %s", w.Code, w.Body.String())
}
if got := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`); got.Code != http.StatusNotFound {
t.Fatalf("disabled endpoint: %d", got.Code)
}
data, err := os.ReadFile(s.deps.Config.SourcePath)
if err != nil {
t.Fatal(err)
}
var restored config.Config
if err := yaml.Unmarshal(data, &restored); err != nil {
t.Fatal(err)
}
p := restored.Knowledge.Public
if p == nil || p.Enabled || len(p.Tags) != 1 || p.Tags[0] != "linux" {
t.Fatalf("persisted settings: %+v", p)
}
s.deps.Config.Knowledge.Public = p
if got := s.publicKnowledgeSettings(); got.Enabled || got.Source != "config" {
t.Fatalf("restored settings: %+v", got)
}
entries := s.audit.Recent(1)
if len(entries) != 1 || entries[0].Action != "knowledge_public_update" {
t.Fatalf("missing audit: %+v", entries)
}

t.Setenv(publicKnowledgeEnabledEnv, "0")
w = publicSettingsRequest(s, "PUT", `{"enabled":true,"tags":["linux"]}`, true)
if w.Code != http.StatusOK {
t.Fatalf("enable: %d %s", w.Code, w.Body.String())
}
result := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_export","arguments":{}}}`)
if result.Code != http.StatusOK || !strings.Contains(result.Body.String(), "Caching Pattern") || strings.Contains(result.Body.String(), "Nil Map Gotcha") || strings.Contains(result.Body.String(), "Secret Roadmap") {
t.Fatalf("public projection: %d %s", result.Code, result.Body.String())
}
}

func TestPublicKnowledgeSettingsEmptyTagsOverrideEnvironment(t *testing.T) {
t.Setenv(publicKnowledgeEnabledEnv, "0")
t.Setenv(publicKnowledgeTagsEnv, "private")
s := publicKnowledgeServer(t)
s.deps.Config.Knowledge.Public = &config.PublicKnowledgeConfig{Enabled: true}
w := publicSettingsRequest(s, "GET", "", true)
if !strings.Contains(w.Body.String(), `"tags":[]`) {
t.Fatalf("tags should be an empty array: %s", w.Body.String())
}
result := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_export"}}`)
if !strings.Contains(result.Body.String(), "Nil Map Gotcha") {
t.Fatalf("empty tags must override env scope: %s", result.Body.String())
}
}

func TestPublicKnowledgeSettingsOwnerGate(t *testing.T) {
s := publicKnowledgeServer(t)
for _, method := range []string{"GET", "PUT"} {
if w := publicSettingsRequest(s, method, `{"enabled":true,"tags":[]}`, false); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous %s: %d", method, w.Code)
}
}
for _, role := range []string{"read", "read-write", "merger", "owner"} {
r := httptest.NewRequest("PUT", "/api/knowledge/public", strings.NewReader(`{"enabled":true,"tags":[]}`))
r.Header.Set("X-Hive-Role", role)
// Even an owner label without the auth middleware's proof must fail.
w := httptest.NewRecorder()
s.handlePublicKnowledgeSettingsUpdate(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("%s: %d", role, w.Code)
}
}
if s.deps.Config.Knowledge.Public != nil {
t.Fatal("unauthorized mutation")
}
}

func TestPublicKnowledgeSettingsInvalidAndFailedSave(t *testing.T) {
t.Setenv(publicKnowledgeEnabledEnv, "0")
s := publicKnowledgeServer(t)
s.deps.Config.SourcePath = filepath.Join(t.TempDir(), "hive.yaml")
for _, body := range []string{`{}`, `{"enabled":null}`, `{"enabled":"yes"}`, `{"enabled":true,"tags":[1]}`} {
if w := publicSettingsRequest(s, "PUT", body, true); w.Code != http.StatusBadRequest {
t.Errorf("%s: %d", body, w.Code)
}
}
// Exercise the real persistence guard without writing any config file.
s.deps.Config.Project.Org = ""
w := publicSettingsRequest(s, "PUT", `{"enabled":true,"tags":[]}`, true)
if w.Code != http.StatusInternalServerError {
t.Fatalf("save failure: %d", w.Code)
}
if s.publicKnowledgeSettings().Enabled || s.deps.Config.Knowledge.Public != nil {
t.Fatal("failed save exposed knowledge")
}
s.deps.Config.SourcePath = ""
if w := publicSettingsRequest(s, "PUT", `{"enabled":true,"tags":[]}`, true); w.Code != http.StatusServiceUnavailable {
t.Fatalf("no persistence: %d", w.Code)
}
}
Loading
Loading