Kaynağa Gözat

fix: security policy change for alpha features and few auth fixes

jamesread 1 hafta önce
ebeveyn
işleme
9344b6c62c

+ 4 - 5
.github/SECURITY_ADVISORY_DUPLICATES.md

@@ -7,11 +7,10 @@ This document lists known duplicate security advisory clusters for [OliveTin/Oli
 ## Triage checklist
 
 1. Search open advisories for the same component and attack path.
-2. Reject if the issue only affects feature-flagged / alpha functionality (`features.*` in config; see [SECURITY.md](../SECURITY.md#feature-flags-alpha--experimental)). Ask the reporter to open a normal GitHub issue instead; do not request a CVE.
-3. Match against clusters below.
-4. If duplicate: close the newer advisory, link to canonical, add reporter to canonical credits.
-5. If unique: accept, patch on a private branch, reassess CVSS with OliveTin context (see SECURITY.md — OliveTin is intentional RCE by design).
-6. Merge fix to `next`, publish advisory, credit reporters in advisory body (not commit message).
+2. Match against clusters below.
+3. If duplicate: close the newer advisory, link to canonical, add reporter to canonical credits.
+4. If unique: accept, patch on a private branch, reassess CVSS with OliveTin context (see SECURITY.md — OliveTin is intentional RCE by design).
+5. Merge fix to `next`, publish advisory, credit reporters in advisory body (not commit message).
 
 ---
 

+ 1 - 2
SECURITY.md

@@ -43,7 +43,7 @@ OliveTin uses global `features.*` flags in `config.yaml` to ship unfinished or e
 
 * **All feature flags default to off.** Enabling a flag is an explicit operator choice.
 * Functionality behind a feature flag is **alpha / experimental** until the flag is removed or the feature is graduated to a stable, default-on product surface.
-* **Security reports and CVEs are not accepted** for bugs that only affect feature-flagged (alpha) functionality. Prefer filing a normal GitHub issue (or a PR) instead of a security advisory.
+* Private security reports **are accepted** for vulnerabilities that affect feature-flagged (alpha) functionality when that flag is enabled. Use Option A or B above; do not file a public issue that discloses exploit details.
 * Reports that affect **stable, non-flagged** code paths remain in scope under this policy, even if a feature flag exists elsewhere in the project.
 
 Operators who enable experimental features should treat them as preview software and avoid relying on them in high-assurance production deployments.
@@ -63,7 +63,6 @@ Maintainers: see [.github/SECURITY_ADVISORY_DUPLICATES.md](.github/SECURITY_ADVI
 Once a vulnerability is reported, the process is;
 
 * Check [.github/SECURITY_ADVISORY_DUPLICATES.md](.github/SECURITY_ADVISORY_DUPLICATES.md) and open advisories for duplicates before accepting.
-* Reject reports that only affect feature-flagged / alpha functionality (see [Feature flags](#feature-flags-alpha--experimental) above); ask the reporter to open a normal issue instead.
 * Accept or reject the report, and communicate with the reporter about next steps.
 * If accepted, patch using a temporary branch, and code review will be requested from the original reporter if they are interested.
 * The severity of the vulnerability will be assessed using CVSS, and the patch will be prioritised accordingly.

+ 1 - 1
service/internal/api/api.go

@@ -637,7 +637,7 @@ func (api *oliveTinAPI) userCanViewBinding(user *authpublic.AuthenticatedUser, b
 }
 
 func (api *oliveTinAPI) bindingEntityTypeAllowed(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
-	if binding == nil || binding.Entity == nil || binding.Action == nil || binding.Action.Entity == "" {
+	if binding == nil || binding.Action == nil || binding.Action.Entity == "" {
 		return true
 	}
 

+ 1 - 1
service/internal/api/apiActions.go

@@ -299,7 +299,7 @@ func buildChoices(arg config.ActionArgument, rr *DashboardRenderRequest) []*apiv
 }
 
 func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string, rr *DashboardRenderRequest) []*apiv1.ActionArgumentChoice {
-	if rr != nil && !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, entityTitle)) {
+	if rr == nil || !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, entityTitle)) {
 		return []*apiv1.ActionArgumentChoice{}
 	}
 

+ 1 - 1
service/internal/api/api_entities_list.go

@@ -136,7 +136,7 @@ func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
 }
 
 func stringContainsFold(value, filter string) bool {
-	return strings.Contains(strings.ToLower(value), filter)
+	return strings.Contains(strings.ToLower(value), strings.ToLower(filter))
 }
 
 func entityFieldsContainFilter(fields map[string]string, filter string) bool {

+ 3 - 3
service/internal/api/api_entity_acl_test.go

@@ -148,9 +148,9 @@ func TestSearchHintsOmitRestrictedEntitiesAndEntityBoundActions(t *testing.T) {
 	assert.NotContains(t, guestEntityKeys, "servers:0")
 
 	guestActionIDs := actionHintBindingIDs(guestHints.Actions)
-	for _, id := range guestActionIDs {
-		assert.NotContains(t, id, "restart")
-	}
+	require.NotEmpty(t, guestActionIDs)
+	assert.Contains(t, guestActionIDs, "ping-printer")
+	assert.NotContains(t, guestActionIDs, "restart")
 
 	adminHints := api.buildSearchHints(admin)
 	require.NotNil(t, adminHints)

+ 24 - 24
service/internal/api/api_init_search_hints_test.go

@@ -36,10 +36,10 @@ func TestInitIncludesEntitySearchHints(t *testing.T) {
 	cfg.Features.HeaderSearch = true
 	cfg.Sanitize()
 
-	ex := executor.DefaultExecutor(cfg)
-	ex.RebuildActionMap()
-	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
-	defer ts.Close()
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
+	defer testServer.Close()
 
 	resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
 	require.NoError(t, err)
@@ -76,10 +76,10 @@ func TestInitOmitsSearchHintsWhenLoginRequired(t *testing.T) {
 	cfg.Features.HeaderSearch = true
 	cfg.Sanitize()
 
-	ex := executor.DefaultExecutor(cfg)
-	ex.RebuildActionMap()
-	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
-	defer ts.Close()
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
+	defer testServer.Close()
 
 	resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
 	require.NoError(t, err)
@@ -99,10 +99,10 @@ func TestInitOmitsSearchHintsWhenHeaderSearchDisabled(t *testing.T) {
 	cfg.Sanitize()
 	require.False(t, cfg.Features.HeaderSearch)
 
-	ex := executor.DefaultExecutor(cfg)
-	ex.RebuildActionMap()
-	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
-	defer ts.Close()
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
+	defer testServer.Close()
 
 	resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
 	require.NoError(t, err)
@@ -135,9 +135,9 @@ func TestBuildSearchHintsRespectsActionACL(t *testing.T) {
 	cfg.Actions[0].Acls = []string{"everyone"}
 	cfg.Sanitize()
 
-	ex := executor.DefaultExecutor(cfg)
-	ex.RebuildActionMap()
-	api := newServer(ex)
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	api := newServer(testExecutor)
 
 	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
 	guest.BuildUserAcls(cfg)
@@ -167,9 +167,9 @@ func TestBuildSearchHintsOmitsHiddenActions(t *testing.T) {
 	}
 	cfg.Sanitize()
 
-	ex := executor.DefaultExecutor(cfg)
-	ex.RebuildActionMap()
-	api := newServer(ex)
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	api := newServer(testExecutor)
 
 	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
 	user.BuildUserAcls(cfg)
@@ -210,9 +210,9 @@ func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) {
 	}
 	cfg.Sanitize()
 
-	ex := executor.DefaultExecutor(cfg)
-	ex.RebuildActionMap()
-	api := newServer(ex)
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	api := newServer(testExecutor)
 
 	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
 	user.BuildUserAcls(cfg)
@@ -251,9 +251,9 @@ func TestBuildSearchHintsPrefersNonEntityActions(t *testing.T) {
 	}
 	cfg.Sanitize()
 
-	ex := executor.DefaultExecutor(cfg)
-	ex.RebuildActionMap()
-	api := newServer(ex)
+	testExecutor := executor.DefaultExecutor(cfg)
+	testExecutor.RebuildActionMap()
+	api := newServer(testExecutor)
 
 	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
 	user.BuildUserAcls(cfg)

+ 14 - 14
service/internal/api/api_search_hints.go

@@ -31,8 +31,8 @@ func (api *oliveTinAPI) buildEntitySearchHints(user *authpublic.AuthenticatedUse
 	out := make([]*apiv1.EntitySearchHint, 0, len(hints))
 
 	for _, hint := range hints {
-		if pb := api.entitySearchHintIfAllowed(user, hint); pb != nil {
-			out = append(out, pb)
+		if allowedHint := api.entitySearchHintIfAllowed(user, hint); allowedHint != nil {
+			out = append(out, allowedHint)
 		}
 	}
 
@@ -57,16 +57,16 @@ func (api *oliveTinAPI) entitySearchHintIfAllowed(user *authpublic.Authenticated
 }
 
 func sortEntitySearchHints(hints []*apiv1.EntitySearchHint) {
-	sort.SliceStable(hints, func(i, j int) bool {
-		if hints[i].Type != hints[j].Type {
-			return hints[i].Type < hints[j].Type
+	sort.SliceStable(hints, func(leftIndex, rightIndex int) bool {
+		if hints[leftIndex].Type != hints[rightIndex].Type {
+			return hints[leftIndex].Type < hints[rightIndex].Type
 		}
 
-		if hints[i].UniqueKey != hints[j].UniqueKey {
-			return hints[i].UniqueKey < hints[j].UniqueKey
+		if hints[leftIndex].UniqueKey != hints[rightIndex].UniqueKey {
+			return hints[leftIndex].UniqueKey < hints[rightIndex].UniqueKey
 		}
 
-		return hints[i].Title < hints[j].Title
+		return hints[leftIndex].Title < hints[rightIndex].Title
 	})
 }
 
@@ -154,15 +154,15 @@ func isSearchableActionBinding(binding *executor.ActionBinding) bool {
 }
 
 func sortActionSearchCandidates(candidates []actionSearchCandidate) {
-	sort.SliceStable(candidates, func(i, j int) bool {
-		if candidates[i].hasEntity != candidates[j].hasEntity {
-			return !candidates[i].hasEntity
+	sort.SliceStable(candidates, func(leftIndex, rightIndex int) bool {
+		if candidates[leftIndex].hasEntity != candidates[rightIndex].hasEntity {
+			return !candidates[leftIndex].hasEntity
 		}
 
-		if candidates[i].title != candidates[j].title {
-			return candidates[i].title < candidates[j].title
+		if candidates[leftIndex].title != candidates[rightIndex].title {
+			return candidates[leftIndex].title < candidates[rightIndex].title
 		}
 
-		return candidates[i].bindingID < candidates[j].bindingID
+		return candidates[leftIndex].bindingID < candidates[rightIndex].bindingID
 	})
 }

+ 10 - 1
service/internal/api/api_test.go

@@ -1091,6 +1091,15 @@ func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
 		entities.ClearEntitiesOfType("room")
 	})
 
+	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{
+		{Name: "room", File: "room.yaml"},
+	}
+	cfg.Sanitize()
+
+	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	user.BuildUserAcls(cfg)
+
 	arg := config.ActionArgument{
 		Type:   "checklist",
 		Entity: "room",
@@ -1099,7 +1108,7 @@ func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
 		},
 	}
 
-	choices := buildChoices(arg, nil)
+	choices := buildChoices(arg, &DashboardRenderRequest{AuthenticatedUser: user, cfg: cfg})
 	require.Len(t, choices, 2)
 	assert.Equal(t, "attic", choices[0].Value)
 	assert.Equal(t, "attic", choices[0].Title)

+ 50 - 0
service/internal/configcheck/rebuild.go

@@ -28,6 +28,7 @@ func Rebuild(cfg *config.Config, extra ...configissues.Issue) {
 	collected := make([]configissues.Issue, 0)
 	collected = append(collected, configissues.CopySticky()...)
 	collected = append(collected, collectActionGroupIssues(cfg)...)
+	collected = append(collected, collectAclReferenceIssues(cfg)...)
 	collected = append(collected, collectArgumentIssues(cfg)...)
 	collected = append(collected, collectIncludeIssues(cfg)...)
 	collected = append(collected, collectTemplateParseIssues(cfg)...)
@@ -81,6 +82,55 @@ func actionIssue(action *config.Action, severity, code, message, source, argName
 	}
 }
 
+func collectAclReferenceIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	out = append(out, collectActionAclIssues(cfg)...)
+	out = append(out, collectEntityAclIssues(cfg)...)
+	return out
+}
+
+func collectActionAclIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, action := range cfg.Actions {
+		if action == nil {
+			continue
+		}
+		for _, aclName := range action.Acls {
+			out = append(out, unknownAclIssue(cfg, aclName, action.ID, action.Title, action.SourceFile)...)
+		}
+	}
+	return out
+}
+
+func collectEntityAclIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, entityFile := range cfg.Entities {
+		if entityFile == nil {
+			continue
+		}
+		for _, aclName := range entityFile.Acls {
+			out = append(out, unknownAclIssue(cfg, aclName, "", entityFile.Name, entityFile.SourceFile)...)
+		}
+	}
+	return out
+}
+
+func unknownAclIssue(cfg *config.Config, aclName, actionID, title, configFile string) []configissues.Issue {
+	if cfg.FindAcl(aclName) != nil {
+		return nil
+	}
+
+	return []configissues.Issue{{
+		Severity:    configissues.SeverityError,
+		Code:        configissues.CodeAclUnknown,
+		Message:     fmt.Sprintf("References unknown ACL %q", aclName),
+		ActionID:    actionID,
+		ActionTitle: title,
+		Source:      aclName,
+		ConfigFile:  configFile,
+	}}
+}
+
 func collectArgumentIssues(cfg *config.Config) []configissues.Issue {
 	out := make([]configissues.Issue, 0)
 	for _, action := range cfg.Actions {

+ 32 - 0
service/internal/configcheck/rebuild_test.go

@@ -249,6 +249,38 @@ func TestRebuildEntityArgumentChoices(t *testing.T) {
 	}
 }
 
+func TestRebuildUnknownAclReferences(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.AccessControlLists = []*config.AccessControlList{
+		{Name: "ops", MatchUsernames: []string{"admin"}, Permissions: config.PermissionsList{View: true}},
+	}
+	cfg.Actions = []*config.Action{
+		{Title: "Restart", ID: "restart", Acls: []string{"ops"}},
+		{Title: "Secret", ID: "secret", Acls: []string{"missing-acl"}},
+	}
+	cfg.Entities = []*config.EntityFile{
+		{Name: "printers", File: "printers.yaml"},
+		{Name: "servers", File: "servers.yaml", Acls: []string{"missing-entity-acl"}},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	issues := configissues.List()
+	require.True(t, hasCode(issues, configissues.CodeAclUnknown))
+
+	unknownSources := make([]string, 0)
+	for _, issue := range issues {
+		if issue.Code == configissues.CodeAclUnknown {
+			assert.Equal(t, configissues.SeverityError, issue.Severity)
+			unknownSources = append(unknownSources, issue.Source)
+		}
+	}
+	assert.Contains(t, unknownSources, "missing-acl")
+	assert.Contains(t, unknownSources, "missing-entity-acl")
+	assert.NotContains(t, unknownSources, "ops")
+}
+
 func hasCode(issues []configissues.Issue, code string) bool {
 	for _, issue := range issues {
 		if issue.Code == code {

+ 1 - 0
service/internal/configissues/issue.go

@@ -19,6 +19,7 @@ const (
 	CodeCronInvalid            = "cron_invalid"
 	CodeCronEntityBinding      = "cron_entity_binding"
 	CodeWatcherPath            = "watcher_path"
+	CodeAclUnknown             = "acl_unknown"
 )
 
 // Issue is a configuration warning or error surfaced on Diagnostics.

+ 1 - 9
service/internal/entities/search_hints.go

@@ -1,24 +1,16 @@
 package entities
 
-// SearchHint is a minimal entity identity for client search indexes.
 type SearchHint struct {
 	Title     string
 	Type      string
 	UniqueKey string
 }
 
-// ListSearchHints returns title/type/key for every entity instance.
-// It holds only a read lock and does not copy entity Data payloads.
 func ListSearchHints() []SearchHint {
 	rwmutex.RLock()
 	defer rwmutex.RUnlock()
 
-	count := 0
-	for _, instances := range entities {
-		count += len(instances)
-	}
-
-	hints := make([]SearchHint, 0, count)
+	hints := make([]SearchHint, 0)
 	for entityType, instances := range entities {
 		for _, entity := range instances {
 			if entity == nil {

+ 4 - 4
service/internal/entities/search_hints_test.go

@@ -30,13 +30,13 @@ func TestListSearchHintsReturnsLightweightIdentities(t *testing.T) {
 		byKey[hint.Type+":"+hint.UniqueKey] = hint
 	}
 
-	host, ok := byKey["search_hint_host:0"]
-	require.True(t, ok)
+	host, hostFound := byKey["search_hint_host:0"]
+	require.True(t, hostFound)
 	assert.Equal(t, "web01", host.Title)
 	assert.Equal(t, "search_hint_host", host.Type)
 	assert.Equal(t, "0", host.UniqueKey)
 
-	app, ok := byKey["search_hint_app:app-1"]
-	require.True(t, ok)
+	app, appFound := byKey["search_hint_app:app-1"]
+	require.True(t, appFound)
 	assert.Equal(t, "Frontend", app.Title)
 }

+ 14 - 14
specs/entity-acls.md

@@ -6,21 +6,21 @@ This spec describes how OliveTin restricts which entity types a user may see, an
 
 ## 1. Scope
 
-Access control applies at the **entity type** level (each entry under entities in configuration), not per instance.
+Access control applies at the **entity type** level (each configured entity definition), not per instance.
 
 - Instances remain data loaded from entity files.
-- Only the **view** permission is consulted for entity types.
-- Action permissions (view, exec, logs, kill) continue to govern actions themselves.
+- Only the ability to **view** an entity type is consulted for these rules.
+- Separate action permissions (view, execute, logs, kill) continue to govern actions themselves.
 
 ---
 
 ## 2. Configuration
 
-Each entity definition may list zero or more ACL names.
+Each entity definition may list zero or more named access-control entries.
 
-- If the list is omitted or empty, the entity type is **unrestricted**: any user who may use the dashboard UI can see that type (same rule as root dashboards with no ACLs).
-- If one or more ACL names are listed, access is an allow list: a matching ACL that grants view, otherwise the default view permission.
-- Adding an ACL to every action does not apply to entity types. An ACL must be listed on the entity definition to restrict it.
+- If the list is omitted or empty, the entity type is **unrestricted**: any user who may use the dashboard UI can see that type (same rule as root dashboards with no access-control list).
+- If one or more named entries are listed, access is an allow list: a matching entry that grants view, otherwise the installation’s default view permission.
+- Marking an access-control entry as applying to every action does not apply to entity types. An entry must be listed on the entity definition to restrict it.
 
 ---
 
@@ -41,13 +41,13 @@ Client search hints for entities include only instances of types the user may vi
 
 Entity-bound action hints (actions generated per entity instance) appear only when the user may view both the action and the entity type. Action view alone is not enough if the entity type is restricted.
 
-Search hints are omitted from Init when guests must log in, when the header search feature flag is off, and the QuickSearch control is not shown until login is no longer required and header search is enabled.
+Search hints are omitted from the initial client bootstrap when guests must log in, when header search is disabled for the installation, and the header search control is not shown until login is no longer required and header search is enabled.
 
-Hints are capped at 100 actions and 50 entity instances **per entity type** per Init response. The client applies the same caps when indexing.
+Hints are capped at 100 actions and 50 entity instances **per entity type** per bootstrap response. The client applies the same caps when indexing.
 
-Dashboards are not included in search hints. Clients build the dashboard search index from Init root dashboard entries (already filtered by dashboard ACL).
+Dashboards are not included in search hints. Clients build the dashboard search index from the bootstrap root dashboard entries (already filtered by dashboard access control).
 
-Entity types with no ACL list remain unrestricted for search and listing.
+Entity types with no access-control list remain unrestricted for search and listing.
 
 ---
 
@@ -70,9 +70,9 @@ Rules:
 
 When a dashboard expands an entity fieldset, instances of types the user cannot view are not rendered.
 
-When an action argument draws choices from an entity type, it must define **exactly one** choice template plus `entity`. OliveTin expands that template per instance. Only instances of types the user may view are included. Users who can view the action but not the entity type must not learn instance names from the argument form.
+When an action argument draws choices from an entity type, it must define **exactly one** choice template and name the entity type. OliveTin expands that template per instance. Only instances of types the user may view are included. Users who can view the action but not the entity type must not learn instance names from the argument form.
 
-Arguments that set `entity` with zero or multiple choices are invalid configuration: startup/reload rejects them, Diagnostics reports an error, the argument form shows no choices, and start/validate requests are rejected.
+Arguments that name an entity type with zero or multiple choice templates are invalid configuration: startup/reload rejects them, Diagnostics reports an error, the argument form shows no choices, and start/validate requests are rejected.
 
 Starting or validating an action rejects entity-backed argument values when:
 
@@ -85,4 +85,4 @@ Guessing an instance name must not bypass entity type access control.
 
 ## 7. Compatibility
 
-Existing entity definitions without ACL lists stay unrestricted. Setting default view to false alone does not hide unrestricted entity types; operators must list ACLs on entity definitions to lock them down.
+Existing entity definitions without access-control lists stay unrestricted. Setting the default view permission to false alone does not hide unrestricted entity types; operators must list access-control entries on entity definitions to lock them down.

+ 13 - 21
specs/feature-flags.md

@@ -10,32 +10,25 @@ Feature flags are **global** settings. They apply to every user and are not over
 
 They are distinct from policy options such as diagnostics or log list visibility, which may differ per user.
 
-All feature-flagged functionality is **alpha / experimental**. Flags default to off. Security advisories and CVEs are not accepted for issues that only affect feature-flagged code; see the project security policy.
+All feature-flagged functionality is **alpha / experimental**. Flags default to off. Private security reports for vulnerabilities that affect enabled experimental functionality are accepted under the project security policy.
 
 ---
 
 ## 2. Configuration
 
-Flags live under `features` in configuration.
+Operators configure flags in a dedicated section of the installation configuration.
 
 - Each flag is a boolean.
 - Omitted flags are **false**.
 - Enabling a feature requires setting it to true explicitly.
 
-Example:
-
-```yaml
-features:
-  headerSearch: true
-```
-
-Unknown keys under `features` are ignored by the configuration loader (same as other unknown config keys).
+Unknown keys in that section are ignored by the configuration loader (same as other unknown config keys).
 
 ---
 
-## 3. Init projection
+## 3. Bootstrap projection
 
-Every Init response includes a `features` object with the current flag values.
+Every client bootstrap response includes the current feature flag values.
 
 Clients must not assume a missing flag means enabled. Treat absent or false as off.
 
@@ -54,11 +47,11 @@ When a feature is on, normal access control still applies to the data and action
 
 ## 5. Header search
 
-`features.headerSearch` controls the header QuickSearch control and Init search hints.
+A header search flag controls the header search control and bootstrap search hints.
 
 - Default: false (alpha / experimental).
-- When false: Init omits search hints; the client does not show QuickSearch or populate the search index.
-- When true: Init includes ACL-filtered search hints (unless guests must log in); the client shows QuickSearch after login is satisfied.
+- When false: bootstrap omits search hints; the client does not show header search or populate the search index.
+- When true: bootstrap includes access-control-filtered search hints (unless guests must log in); the client shows header search after login is satisfied.
 
 ---
 
@@ -66,9 +59,8 @@ When a feature is on, normal access control still applies to the data and action
 
 To add a flag:
 
-1. Add a boolean field under `features` in configuration (default false).
-2. Add the same field to the Init `features` message.
-3. Project the value on every Init response.
-4. Gate server work and UI behind the flag.
-5. Document the flag as alpha / experimental and update this spec’s feature list.
-6. Keep the security policy statement that CVEs are not accepted for feature-flagged functionality until the feature graduates (flag removed or stable default-on).
+1. Add a boolean setting in the feature-flags configuration section (default false).
+2. Expose the same setting on every client bootstrap response.
+3. Gate server work and UI behind the flag.
+4. Document the flag as alpha / experimental and update this spec’s feature list.
+5. Keep the security policy aligned: private reports remain accepted for enabled experimental functionality until the feature graduates (flag removed or stable default-on).