From a5b78aa7b719b103d683321ce7b1eb43ec8e9d43 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Wed, 30 Sep 2026 19:16:58 +0100 Subject: [PATCH 1/4] fix(gateway): scan csv variables element by element in guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The length heuristic flags any PUBLIC value over 64 characters that has no spaces and no `http` prefix (gateway/internal/guardrails/guardrails.go:105 before this change). A `csv` value of short kebab-case feature flags fits that description once the list passes 64 characters. Under `strict_guardrails: true` (`--strict`) the gateway then refuses to start (gateway/internal/server/server.go:84-89), so adding one flag to a values file became a failed rollout. Reproduced with a 67-character comma list on v0.1.7. The same list with ", " separators started, because it then contained spaces. `Scan` scanned `v.Value` whole and had no way to learn the declared type. It now takes the loaded manifest (nil when there is none). A variable declared `type: csv` is split on commas, each element is trimmed, and every heuristic (known format, entropy, length) runs on each element. The check is not weakened: an 80-character opaque token inside a list is still flagged, and the warning and log name its position (`csv element 2: ...`, `csv_element=2`). A known-format token past the first element is now caught; before, only a prefix of the joined string was checked. Every other type, and any variable with no manifest entry, is scanned whole as before. Spec §3.3 and the guardrails section of the docs describe the per-element rule. --- .../docs/concepts/variable-classification.mdx | 2 + docs/src/content/docs/guides/manifest.mdx | 2 +- gateway/internal/guardrails/guardrails.go | 108 ++++++++++-------- .../internal/guardrails/guardrails_test.go | 98 ++++++++++++++-- gateway/internal/server/server.go | 2 +- gateway/internal/server/server_test.go | 2 +- gateway/internal/server/strict_test.go | 60 ++++++++++ spec/REP-RFC-0001.md | 2 + 8 files changed, 213 insertions(+), 63 deletions(-) create mode 100644 gateway/internal/server/strict_test.go diff --git a/docs/src/content/docs/concepts/variable-classification.mdx b/docs/src/content/docs/concepts/variable-classification.mdx index 75a7d8d..6e4d785 100644 --- a/docs/src/content/docs/concepts/variable-classification.mdx +++ b/docs/src/content/docs/concepts/variable-classification.mdx @@ -70,6 +70,8 @@ At startup, the gateway scans all `REP_PUBLIC_*` values for patterns indicating | **Known key formats** | AWS keys (`AKIA...`), JWTs (`eyJ...`), GitHub tokens (`ghp_...`), Stripe keys (`sk_live_...`), private keys (`-----BEGIN`) | | **Length anomalies** | Strings > 64 characters that may be encoded secrets | +A variable declared `type: csv` in the [manifest](/guides/manifest/) is scanned one element at a time (split on commas, whitespace trimmed) rather than as one string. A long list of short feature flags therefore doesn't trip the length check under `--strict`. A long or secret-shaped token inside the list is still flagged, and the warning names its position (`csv element 3: …`). Every other value is scanned whole. + When a potential misclassification is detected: - The gateway logs a **WARNING** with the variable name (never the value) diff --git a/docs/src/content/docs/guides/manifest.mdx b/docs/src/content/docs/guides/manifest.mdx index d71ce01..f4138fe 100644 --- a/docs/src/content/docs/guides/manifest.mdx +++ b/docs/src/content/docs/guides/manifest.mdx @@ -91,7 +91,7 @@ settings: | `url` | Must be a valid URL | | `number` | Must parse as a number | | `boolean` | Must be `true`, `false`, `1`, or `0` | -| `csv` | Comma-separated values | +| `csv` | Comma-separated values. [Guardrails](/concepts/variable-classification/#automatic-secret-detection-guardrails) scan each element separately | | `json` | Must be valid JSON | | `enum` | Must match one of the `values` array entries | diff --git a/gateway/internal/guardrails/guardrails.go b/gateway/internal/guardrails/guardrails.go index 07cbb6a..fb0f5d4 100644 --- a/gateway/internal/guardrails/guardrails.go +++ b/gateway/internal/guardrails/guardrails.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/ruachtech/rep/gateway/internal/config" + "github.com/ruachtech/rep/gateway/internal/manifest" ) // Warning represents a guardrail detection event. @@ -61,64 +62,75 @@ var knownSecretPrefixes = []struct { // // Per REP-RFC-0001 §3.3, the gateway MUST scan and MUST log warnings. // If strict mode is enabled, the caller should treat warnings as errors. -func Scan(vars *config.ClassifiedVars, logger *slog.Logger) *Result { +// +// m is the loaded manifest, or nil. A variable it declares as type csv is +// scanned element by element (split on commas, trimmed), with every +// heuristic applied to every element (§3.3). +func Scan(vars *config.ClassifiedVars, m *manifest.Manifest, logger *slog.Logger) *Result { result := &Result{} for _, v := range vars.Public { - // Check known secret formats. - for _, kp := range knownSecretPrefixes { - if strings.HasPrefix(v.Value, kp.prefix) { - w := Warning{ - VariableName: v.Name, - OriginalKey: v.OriginalKey, - DetectionType: "known_format", - Message: fmt.Sprintf("value matches known %s format (prefix: %s)", kp.service, kp.prefix), - } - result.Warnings = append(result.Warnings, w) - logger.Warn("rep.guardrail.warning", - "variable_name", v.Name, - "detection_type", "known_format", - "detail", w.Message, - ) - break // One match is enough per variable. - } + if !isCSV(m, v.Name) { + result.scanValue(v, v.Value, 0, logger) + continue + } + for i, elem := range strings.Split(v.Value, ",") { + result.scanValue(v, strings.TrimSpace(elem), i+1, logger) } + } + + return result +} + +// isCSV reports whether the manifest declares name with type csv. +func isCSV(m *manifest.Manifest, name string) bool { + if m == nil { + return false + } + d := m.Variables[name] + return d != nil && d.Type == "csv" +} - // Check Shannon entropy. - entropy := shannonEntropy(v.Value) - if entropy > 4.5 && len(v.Value) > 16 { - w := Warning{ - VariableName: v.Name, - OriginalKey: v.OriginalKey, - DetectionType: "high_entropy", - Message: fmt.Sprintf("value has high entropy (%.2f bits/char) — may be a secret", entropy), - } - result.Warnings = append(result.Warnings, w) - logger.Warn("rep.guardrail.warning", - "variable_name", v.Name, - "detection_type", "high_entropy", - "entropy", fmt.Sprintf("%.2f", entropy), - ) +// scanValue runs every heuristic on value, which is the whole of v's value +// when element is 0 and its element'th csv element (1-based) otherwise. +func (r *Result) scanValue(v config.Variable, value string, element int, logger *slog.Logger) { + warn := func(detectionType, message string, extra ...any) { + args := append([]any{"variable_name", v.Name, "detection_type", detectionType}, extra...) + if element > 0 { + message = fmt.Sprintf("csv element %d: %s", element, message) + args = append(args, "csv_element", element) } + r.Warnings = append(r.Warnings, Warning{ + VariableName: v.Name, + OriginalKey: v.OriginalKey, + DetectionType: detectionType, + Message: message, + }) + logger.Warn("rep.guardrail.warning", args...) + } - // Check length anomaly. - if len(v.Value) > 64 && !strings.Contains(v.Value, " ") && !strings.HasPrefix(v.Value, "http") { - w := Warning{ - VariableName: v.Name, - OriginalKey: v.OriginalKey, - DetectionType: "length_anomaly", - Message: fmt.Sprintf("value is %d chars with no spaces and no URL prefix — may be an encoded secret", len(v.Value)), - } - result.Warnings = append(result.Warnings, w) - logger.Warn("rep.guardrail.warning", - "variable_name", v.Name, - "detection_type", "length_anomaly", - "length", len(v.Value), - ) + // Check known secret formats. + for _, kp := range knownSecretPrefixes { + if strings.HasPrefix(value, kp.prefix) { + msg := fmt.Sprintf("value matches known %s format (prefix: %s)", kp.service, kp.prefix) + warn("known_format", msg, "detail", msg) + break // One match is enough per value. } } - return result + // Check Shannon entropy. + if entropy := shannonEntropy(value); entropy > 4.5 && len(value) > 16 { + warn("high_entropy", + fmt.Sprintf("value has high entropy (%.2f bits/char) — may be a secret", entropy), + "entropy", fmt.Sprintf("%.2f", entropy)) + } + + // Check length anomaly. + if len(value) > 64 && !strings.Contains(value, " ") && !strings.HasPrefix(value, "http") { + warn("length_anomaly", + fmt.Sprintf("value is %d chars with no spaces and no URL prefix — may be an encoded secret", len(value)), + "length", len(value)) + } } // shannonEntropy calculates the Shannon entropy (bits per character) of a string. diff --git a/gateway/internal/guardrails/guardrails_test.go b/gateway/internal/guardrails/guardrails_test.go index 57d70f0..835ff75 100644 --- a/gateway/internal/guardrails/guardrails_test.go +++ b/gateway/internal/guardrails/guardrails_test.go @@ -2,9 +2,12 @@ package guardrails import ( "log/slog" + "slices" + "strings" "testing" "github.com/ruachtech/rep/gateway/internal/config" + "github.com/ruachtech/rep/gateway/internal/manifest" ) func makeVars(publicVars ...config.Variable) *config.ClassifiedVars { @@ -26,7 +29,7 @@ func TestScan_NoWarnings(t *testing.T) { makeVar("FEATURE_FLAGS", "dark-mode,beta"), ) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) if result.HasWarnings() { t.Errorf("expected no warnings, got %d: %+v", len(result.Warnings), result.Warnings) } @@ -35,7 +38,7 @@ func TestScan_NoWarnings(t *testing.T) { func TestScan_KnownFormat_AWS(t *testing.T) { vars := makeVars(makeVar("KEY", "AKIAIOSFODNN7EXAMPLE")) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) if !result.HasWarnings() { t.Fatal("expected warning for AWS key format") } @@ -47,7 +50,7 @@ func TestScan_KnownFormat_AWS(t *testing.T) { func TestScan_KnownFormat_JWT(t *testing.T) { vars := makeVars(makeVar("TOKEN", "eyJhbGciOiJIUzI1NiJ9.test.payload")) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) found := false for _, w := range result.Warnings { if w.DetectionType == "known_format" { @@ -63,7 +66,7 @@ func TestScan_KnownFormat_JWT(t *testing.T) { func TestScan_KnownFormat_GitHub(t *testing.T) { vars := makeVars(makeVar("GH", "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) found := false for _, w := range result.Warnings { if w.DetectionType == "known_format" { @@ -78,7 +81,7 @@ func TestScan_KnownFormat_GitHub(t *testing.T) { func TestScan_KnownFormat_Stripe(t *testing.T) { vars := makeVars(makeVar("SK", "sk_live_xxxxxxxxxxxxxxxxxxxxxxxx")) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) found := false for _, w := range result.Warnings { if w.DetectionType == "known_format" { @@ -93,7 +96,7 @@ func TestScan_KnownFormat_Stripe(t *testing.T) { func TestScan_KnownFormat_OpenAI(t *testing.T) { vars := makeVars(makeVar("AI", "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) found := false for _, w := range result.Warnings { if w.DetectionType == "known_format" { @@ -108,7 +111,7 @@ func TestScan_KnownFormat_OpenAI(t *testing.T) { func TestScan_KnownFormat_PrivateKey(t *testing.T) { vars := makeVars(makeVar("CERT", "-----BEGIN RSA PRIVATE KEY-----")) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) found := false for _, w := range result.Warnings { if w.DetectionType == "known_format" { @@ -126,7 +129,7 @@ func TestScan_HighEntropy(t *testing.T) { vars := makeVars(makeVar("RANDOM", highEntropy)) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) found := false for _, w := range result.Warnings { if w.DetectionType == "high_entropy" { @@ -144,7 +147,7 @@ func TestScan_LengthAnomaly(t *testing.T) { vars := makeVars(makeVar("LONG", longValue)) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) found := false for _, w := range result.Warnings { if w.DetectionType == "length_anomaly" { @@ -162,7 +165,7 @@ func TestScan_NoFalsePositive_URL(t *testing.T) { vars := makeVars(makeVar("CDN", longURL)) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) for _, w := range result.Warnings { if w.DetectionType == "length_anomaly" { t.Error("URL should not trigger length_anomaly") @@ -174,7 +177,7 @@ func TestScan_NoFalsePositive_ShortValue(t *testing.T) { // Short high-entropy string (under 16 chars) should not trigger. vars := makeVars(makeVar("SHORT", "aB3cD4eF5gH")) - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) for _, w := range result.Warnings { if w.DetectionType == "high_entropy" { t.Error("short values should not trigger entropy warning") @@ -190,7 +193,7 @@ func TestScanOnlyScansPublic(t *testing.T) { Server: []config.Variable{{Name: "DB", Value: "sk_live_secret", Tier: config.TierServer}}, } - result := Scan(vars, slog.Default()) + result := Scan(vars, nil, slog.Default()) if result.HasWarnings() { t.Error("guardrails should only scan PUBLIC vars") } @@ -224,3 +227,74 @@ func TestShannonEntropy_HighValue(t *testing.T) { t.Errorf("expected high entropy for diverse string, got %f", e) } } + +func TestScan_CSVJudgedByElement(t *testing.T) { + // 67 chars of short kebab-case flags: long, no spaces, no URL prefix. + const flagList = "dark-mode,new-checkout,beta-search,lyrics-web,stage-timer,obs-scene" + opaque := strings.Repeat("Zm9vYmFy", 10) // 80 chars, base64-looking + declared := func(typ string) *manifest.Manifest { + return &manifest.Manifest{Variables: map[string]*manifest.VarDecl{ + "FEATURE_FLAGS": {Tier: "public", Type: typ}, + }} + } + csv, str := declared("csv"), declared("string") + + tests := []struct { + name string + manifest *manifest.Manifest + value string + want []string // detection types, in order + wantMsg string // substring of the first warning's message + }{ + {name: "long csv of short tokens passes", manifest: csv, value: flagList}, + {name: "spaces after commas are trimmed", manifest: csv, value: strings.ReplaceAll(flagList, ",", " , ")}, + {name: "empty csv passes", manifest: csv, value: ""}, + { + name: "one long opaque token inside a csv is flagged", manifest: csv, + value: "dark-mode," + opaque + ",beta", + want: []string{"length_anomaly"}, wantMsg: "csv element 2: value is 80 chars", + }, + { + name: "a known secret format past the first element is flagged", manifest: csv, + value: "dark-mode,ghp_" + strings.Repeat("ab", 18), + want: []string{"known_format"}, wantMsg: "csv element 2: value matches known GitHub Personal Access Token", + }, + { + name: "a high-entropy element is flagged", manifest: csv, + value: "beta,aB3$xY9!mK2@pQ7#nL5&wR8*", + want: []string{"high_entropy"}, wantMsg: "csv element 2: value has high entropy", + }, + { + name: "the same comma list typed string is still flagged whole", manifest: str, + value: flagList, + want: []string{"length_anomaly"}, wantMsg: "value is 67 chars", + }, + { + name: "without a manifest a comma list is flagged whole", manifest: nil, + value: flagList, + want: []string{"length_anomaly"}, wantMsg: "value is 67 chars", + }, + { + name: "a long opaque non-csv value is flagged", manifest: str, + value: opaque, + want: []string{"length_anomaly"}, wantMsg: "value is 80 chars", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Scan(makeVars(makeVar("FEATURE_FLAGS", tt.value)), tt.manifest, slog.Default()) + + var got []string + for _, w := range result.Warnings { + got = append(got, w.DetectionType) + } + if !slices.Equal(got, tt.want) { + t.Fatalf("detections = %v, want %v (%+v)", got, tt.want, result.Warnings) + } + if tt.wantMsg != "" && !strings.HasPrefix(result.Warnings[0].Message, tt.wantMsg) { + t.Errorf("message = %q, want prefix %q", result.Warnings[0].Message, tt.wantMsg) + } + }) + } +} diff --git a/gateway/internal/server/server.go b/gateway/internal/server/server.go index 244418d..6abf796 100644 --- a/gateway/internal/server/server.go +++ b/gateway/internal/server/server.go @@ -88,7 +88,7 @@ func New(cfg *config.Config, logger *slog.Logger, version string) (*Server, erro // Step 3–4: Run secret detection guardrails. logger.Info("running guardrail scan on PUBLIC tier variables") - gr := guardrails.Scan(vars, logger) + gr := guardrails.Scan(vars, cfg.Manifest, logger) if gr.HasWarnings() && cfg.Strict { return nil, fmt.Errorf( diff --git a/gateway/internal/server/server_test.go b/gateway/internal/server/server_test.go index 43d6fdd..f82456b 100644 --- a/gateway/internal/server/server_test.go +++ b/gateway/internal/server/server_test.go @@ -24,7 +24,7 @@ func buildTestMux(t *testing.T, vars *config.ClassifiedVars, staticDir string, h t.Helper() logger := slog.Default() - gr := guardrails.Scan(vars, logger) + gr := guardrails.Scan(vars, nil, logger) keys, err := repcrypto.GenerateKeys() if err != nil { diff --git a/gateway/internal/server/strict_test.go b/gateway/internal/server/strict_test.go new file mode 100644 index 0000000..2496b4b --- /dev/null +++ b/gateway/internal/server/strict_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "io" + "log/slog" + "strings" + "testing" + + "github.com/ruachtech/rep/gateway/internal/config" + "github.com/ruachtech/rep/gateway/internal/manifest" +) + +// flagList is 67 characters of short kebab-case flags joined by commas. +// Case-by-case coverage of the heuristics is in the guardrails package; this +// test proves the manifest reaches the scan and decides a --strict start. +const flagList = "dark-mode,new-checkout,beta-search,lyrics-web,stage-timer,obs-scene" + +func TestNew_StrictGuardrailsJudgeCSVByElement(t *testing.T) { + if len(flagList) <= 64 { + t.Fatalf("flagList is %d chars; it must exceed the 64-char length heuristic", len(flagList)) + } + opaque := strings.Repeat("Zm9vYmFy", 10) // 80 chars, base64-looking + + tests := []struct { + name string + typ string + value string + wantErr bool + }{ + {name: "long csv of short tokens starts", typ: "csv", value: flagList}, + {name: "csv holding one long opaque token is refused", typ: "csv", value: "dark-mode," + opaque + ",beta", wantErr: true}, + {name: "the same comma list typed string is still refused", typ: "string", value: flagList, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearREPEnv(t) + t.Setenv("REP_PUBLIC_FEATURE_FLAGS", tt.value) + + cfg := &config.Config{ + Mode: "embedded", + StaticDir: "../../testdata/static", + Strict: true, + Manifest: &manifest.Manifest{Variables: map[string]*manifest.VarDecl{ + "FEATURE_FLAGS": {Tier: "public", Type: tt.typ}, + }}, + } + _, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), "0.0.0-test") + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), "refusing to start") { + t.Fatalf("New() error = %v, want a strict-mode refusal", err) + } + return + } + if err != nil { + t.Fatalf("New() error = %v, want the gateway to start", err) + } + }) + } +} diff --git a/spec/REP-RFC-0001.md b/spec/REP-RFC-0001.md index 2afc649..c2fc167 100644 --- a/spec/REP-RFC-0001.md +++ b/spec/REP-RFC-0001.md @@ -107,6 +107,8 @@ At startup, the gateway MUST scan all `REP_PUBLIC_*` values for patterns that in | Known key formats | AWS access keys (`AKIA...`), JWT tokens (`eyJ...`), GitHub tokens (`ghp_...`, `gho_...`), Stripe keys (`sk_live_...`, `pk_live_...`), private keys (`-----BEGIN`) | | Length anomalies | Strings > 64 characters that appear to be encoded secrets | +When a manifest (§6) declares a variable with `type: csv`, the gateway MUST apply these heuristics to each element of the value (split on `,`, surrounding whitespace trimmed) and not to the joined string. Every heuristic still applies to every element, so a long or secret-shaped token inside a list is detected as it would be on its own. Variables of any other type, or with no manifest declaration, are scanned whole. + When a potential misclassification is detected: - The gateway MUST log a **WARNING** with the variable name (but NOT the value). From 85506ee6be228ca595887b1d495486485cf4e11b Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Thu, 1 Oct 2026 13:23:27 +0100 Subject: [PATCH 2/4] docs(guides): add a feature-flags guide The repo used `FEATURE_FLAGS` as an example throughout but had no page on using it. The new `guides/feature-flags` page, under Guides next to the manifest guide, covers: - one `FEATURE_FLAGS: {tier: public, type: csv, default: ""}` variable rather than one variable per flag, with an optional pattern; - reading it with the SDK, where a flag is off unless named; - that flags are public and are not access control; - the per-element guardrail, and the dev-plugin caveat; - flipping a flag by changing the environment and restarting, or with hot reload when the value comes from `--env-file`; - what it isn't: no per-user targeting, rollouts or experiments. The agents playbook and the README's SDK example link to it. --- README.md | 2 +- docs/src/content/docs/agents.mdx | 1 + .../src/content/docs/guides/feature-flags.mdx | 91 +++++++++++++++++++ docs/src/sidebar.mjs | 1 + 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/docs/guides/feature-flags.mdx diff --git a/README.md b/README.md index 15448d5..fb5f939 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ import { rep } from '@rep-protocol/sdk'; // PUBLIC vars — synchronous, no async, no loading state const apiUrl = rep.get('API_URL'); -const flags = rep.get('FEATURE_FLAGS'); +const flags = rep.get('FEATURE_FLAGS'); // see rep-protocol.dev/guides/feature-flags/ // SENSITIVE vars — encrypted, decrypted on demand const key = await rep.getSecure('ANALYTICS_KEY'); diff --git a/docs/src/content/docs/agents.mdx b/docs/src/content/docs/agents.mdx index 3b464df..0b7f27c 100644 --- a/docs/src/content/docs/agents.mdx +++ b/docs/src/content/docs/agents.mdx @@ -415,6 +415,7 @@ When this page is not enough, these are the pages to read next — each is avail - [Quick Start](/quick-start.md) — the five-minute path - [How REP Works](/concepts/how-it-works.md) — startup sequence and injection mechanics - [Variable Classification](/concepts/variable-classification.md) — tier rules and guardrail internals +- [Feature Flags](/guides/feature-flags.md) — one `csv` variable, flipped without a rebuild - [Security Model](/concepts/security-model.md) — threat analyses and hardening - [Wire Format](/concepts/wire-format.md) — payload JSON, encrypted blob layout, HMAC - [SDK API](/reference/sdk.md) — full client reference diff --git a/docs/src/content/docs/guides/feature-flags.mdx b/docs/src/content/docs/guides/feature-flags.mdx new file mode 100644 index 0000000..4ad75c4 --- /dev/null +++ b/docs/src/content/docs/guides/feature-flags.mdx @@ -0,0 +1,91 @@ +--- +title: Feature Flags — One csv Variable, Flipped Without a Rebuild +description: Use a single REP_PUBLIC_FEATURE_FLAGS csv variable for deployment-wide feature flags. Manifest declaration, SDK reads, guardrail behaviour, flipping flags without a rebuild, and what REP flags are not. +--- + +import { Aside } from '@astrojs/starlight/components'; + +REP can carry simple feature flags: switches that are on or off for a whole deployment and change without a rebuild. Use one `csv` variable that lists the flags that are on. + +## Declare one variable + +```yaml +# .rep.yaml +variables: + FEATURE_FLAGS: + tier: public + type: csv + default: "" + pattern: "([a-z0-9-]+(,[a-z0-9-]+)*)?" # optional: kebab-case names, no spaces + description: "Flags that are on, comma-separated" +``` + +Use one variable for all flags, not one variable per flag. Adding a flag is then a value change, not a manifest change, and every flag is read the same way. + +`default: ""` means that when `REP_PUBLIC_FEATURE_FLAGS` is unset, the gateway injects an empty list, so every flag is off. The optional `pattern` rejects typos such as stray spaces or capitals at startup, and the gateway applies it to the default as well. + +## Read it with the SDK + +```typescript +import { rep } from '@rep-protocol/sdk'; + +const enabled = new Set( + rep.get('FEATURE_FLAGS', '').split(',').map((f) => f.trim()).filter(Boolean), +); + +export const isOn = (flag: string) => enabled.has(flag); +``` + +A flag is **off unless it is named**. There is no list of known flags to keep in sync and no "false" value to set. An unknown name is simply never checked. + +The second argument to `rep.get()` covers the case with no gateway at all, such as unit tests or a plain `vite dev`. Behind the gateway, the manifest default already guarantees a string. + +For example, a single image might serve a marketing landing page at `/` on its hosted deployment and redirect `/` straight to sign-in everywhere else: + +```bash +# hosted deployment +REP_PUBLIC_FEATURE_FLAGS=landing-page +# self-hosted deployment: leave it unset — every flag is off +``` + +```typescript +if (location.pathname === '/' && !isOn('landing-page')) location.replace('/login'); +``` + +## Flags are public + +`FEATURE_FLAGS` is a PUBLIC variable, so its value is in the page source of every page the gateway serves. + +- **Never put a secret in a flag name.** That includes a token, a customer name, or an unreleased product's codename you can't disclose. +- **A flag is not access control.** Hiding a button does not stop anyone calling the API behind it, so the server must still check permissions. A flag decides what the UI shows, not what a user may do. + +## Guardrails judge each flag, not the list + +The gateway's [guardrails](/concepts/variable-classification/#automatic-secret-detection-guardrails) check PUBLIC values for things that look like leaked secrets. For a `csv` variable they check each element separately. Every check (known key formats, entropy, length over 64 characters) runs on every element. + +- A long list of short flags never trips the checks, so adding a flag can't make `--strict` refuse to start. +- An element that looks like a secret is still flagged, and the warning names its position (`csv element 3: …`). + + + +## Flipping a flag + +You flip a flag by changing the environment, not the image: + +- **Restart:** set `REP_PUBLIC_FEATURE_FLAGS` and restart the container or roll the deployment. This always works. +- **Hot reload:** with `--hot-reload`, the gateway can pick up a change without a restart, but only if it reads the value from a file it re-reads (`--env-file`). It cannot see a change to its own process environment. Pages that use `rep.onChange('FEATURE_FLAGS', …)` or the framework adapters (`useRep()` and the others) update in place. See [Hot Reload](/concepts/hot-reload/) and the [Kubernetes recipe](/deployment/kubernetes/). + +Either way there is no rebuild. The same image carries a different flag list into each environment. + +## What this is not + +A REP flag has one value per deployment, and every visitor to that deployment sees the same flags. That makes it a good fit for environment differences, kill switches and staged launches. It cannot do: + +- per-user or per-account targeting; +- percentage or gradual rollouts; +- A/B tests or experiments; +- an audit trail or a UI for non-engineers. + +If you need any of those, use a dedicated feature-flag service. REP can still deliver that service's public client key as a PUBLIC variable. diff --git a/docs/src/sidebar.mjs b/docs/src/sidebar.mjs index 8fb6d82..fe991b2 100644 --- a/docs/src/sidebar.mjs +++ b/docs/src/sidebar.mjs @@ -41,6 +41,7 @@ export const sidebar = [ label: 'Guides', items: [ { label: 'Manifest File', slug: 'guides/manifest' }, + { label: 'Feature Flags', slug: 'guides/feature-flags' }, { label: 'Testing', slug: 'guides/testing' }, { label: 'Migration', From 5baaa3fac22698d4371e5a9ac4c5cb673149739b Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Thu, 1 Oct 2026 13:23:27 +0100 Subject: [PATCH 3/4] docs: document per-element csv guardrails on every user-facing surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gateway README "Upgrading / To 0.1.8": csv values are scanned element by element, warnings name the element (`csv_element`), and a known key format is now caught anywhere in the list. - Variable classification: the `csv_element` log attribute, and that the dev plugins scan whole values. - Vite and Next.js plugin READMEs and reference pages, and the local development guide: the plugins don't read the manifest, so with `strict` a long flag list can throw in development even though the gateway accepts it. Documented, not fixed: a fix needs a new plugin option or a YAML parser in two packages. - REP-RFC-0001 revision history: the §3.3 rule joins the 0.2.0 entry. --- docs/src/content/docs/concepts/variable-classification.mdx | 2 +- docs/src/content/docs/guides/development.mdx | 2 +- docs/src/content/docs/guides/manifest.mdx | 2 +- docs/src/content/docs/reference/plugins/next.mdx | 1 + docs/src/content/docs/reference/plugins/vite.mdx | 2 +- gateway/README.md | 1 + plugins/next/README.md | 2 +- plugins/vite/README.md | 2 +- spec/REP-RFC-0001.md | 2 +- 9 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/concepts/variable-classification.mdx b/docs/src/content/docs/concepts/variable-classification.mdx index 6e4d785..bae0d9f 100644 --- a/docs/src/content/docs/concepts/variable-classification.mdx +++ b/docs/src/content/docs/concepts/variable-classification.mdx @@ -70,7 +70,7 @@ At startup, the gateway scans all `REP_PUBLIC_*` values for patterns indicating | **Known key formats** | AWS keys (`AKIA...`), JWTs (`eyJ...`), GitHub tokens (`ghp_...`), Stripe keys (`sk_live_...`), private keys (`-----BEGIN`) | | **Length anomalies** | Strings > 64 characters that may be encoded secrets | -A variable declared `type: csv` in the [manifest](/guides/manifest/) is scanned one element at a time (split on commas, whitespace trimmed) rather than as one string. A long list of short feature flags therefore doesn't trip the length check under `--strict`. A long or secret-shaped token inside the list is still flagged, and the warning names its position (`csv element 3: …`). Every other value is scanned whole. +A variable declared `type: csv` in the [manifest](/guides/manifest/) is scanned one element at a time (split on commas, whitespace trimmed) rather than as one string. A long list of short feature flags therefore doesn't trip the length check under `--strict`. A long or secret-shaped token inside the list is still flagged, and the warning names its position (`csv element 3: …` in the message, `csv_element` in the log). Every other value is scanned whole. The Vite and Next.js dev plugins don't read the manifest, so they scan every value whole. See [Feature Flags](/guides/feature-flags/). When a potential misclassification is detected: diff --git a/docs/src/content/docs/guides/development.mdx b/docs/src/content/docs/guides/development.mdx index 9789612..6709839 100644 --- a/docs/src/content/docs/guides/development.mdx +++ b/docs/src/content/docs/guides/development.mdx @@ -69,7 +69,7 @@ The easiest way to get full REP support in development. The plugin injects the s ## Option B: Default values (simplest) diff --git a/docs/src/content/docs/guides/manifest.mdx b/docs/src/content/docs/guides/manifest.mdx index f4138fe..31163a3 100644 --- a/docs/src/content/docs/guides/manifest.mdx +++ b/docs/src/content/docs/guides/manifest.mdx @@ -91,7 +91,7 @@ settings: | `url` | Must be a valid URL | | `number` | Must parse as a number | | `boolean` | Must be `true`, `false`, `1`, or `0` | -| `csv` | Comma-separated values. [Guardrails](/concepts/variable-classification/#automatic-secret-detection-guardrails) scan each element separately | +| `csv` | Comma-separated values. [Guardrails](/concepts/variable-classification/#automatic-secret-detection-guardrails) scan each element separately. See [Feature Flags](/guides/feature-flags/) | | `json` | Must be valid JSON | | `enum` | Must match one of the `values` array entries | diff --git a/docs/src/content/docs/reference/plugins/next.mdx b/docs/src/content/docs/reference/plugins/next.mdx index 16da353..c9516e3 100644 --- a/docs/src/content/docs/reference/plugins/next.mdx +++ b/docs/src/content/docs/reference/plugins/next.mdx @@ -65,6 +65,7 @@ Only needed if you read `REP_SENSITIVE_*` variables with `rep.getSecure()` / `us - **Server Component only.** `RepScript` reads the filesystem (`env`) and generates keys server-side; it cannot be used in a Client Component. Consume the injected payload from Client Components via `rep.get()` / `useRep()` as usual — the SDK reads the DOM, not React context. - **Script content is Go-escaped** (`<`, `>`, `&` → `<`, `>`, `&`) before being written via `dangerouslySetInnerHTML`, preventing `` breakout from untrusted env values. The tag still carries `type="application/json"`, so the browser never executes it. - **Ephemeral keys are process-scoped**, held in a module-level singleton (`getOrCreateKeys()`) so the same key survives across route handler invocations within one `next dev` process. +- **Guardrails scan every `REP_PUBLIC_*` value whole.** `RepScript` doesn't read `.rep.yaml`, so unlike the gateway it doesn't scan a `csv`-typed variable element by element. With `strict`, a comma-separated list longer than 64 characters (a long feature-flag list, for example) can throw here even though the gateway accepts it. - **Byte-identical payload** to the Go gateway and the Vite plugin (sorted keys, same HMAC + SRI format) — `rep.verify()` and `rep.meta()` behave the same regardless of which one produced the payload. ## App Router only diff --git a/docs/src/content/docs/reference/plugins/vite.mdx b/docs/src/content/docs/reference/plugins/vite.mdx index 3617ca2..c13439f 100644 --- a/docs/src/content/docs/reference/plugins/vite.mdx +++ b/docs/src/content/docs/reference/plugins/vite.mdx @@ -48,7 +48,7 @@ repPlugin({ - **Injects `` breakout from untrusted env values. The tag still carries `type="application/json"`, so the browser never executes it. - **Ephemeral keys are process-scoped**, held in a module-level singleton (`getOrCreateKeys()`) so the same key survives across route handler invocations within one `next dev` process. -- **Guardrails scan every `REP_PUBLIC_*` value whole.** `RepScript` doesn't read `.rep.yaml`, so unlike the gateway it doesn't scan a `csv`-typed variable element by element. With `strict`, a comma-separated list longer than 64 characters (a long feature-flag list, for example) can throw here even though the gateway accepts it. +- **Guardrails scan every `REP_PUBLIC_*` value whole**, including `csv` values the gateway scans by element ([details](/guides/feature-flags/#dev-plugins)). - **Byte-identical payload** to the Go gateway and the Vite plugin (sorted keys, same HMAC + SRI format) — `rep.verify()` and `rep.meta()` behave the same regardless of which one produced the payload. ## App Router only diff --git a/docs/src/content/docs/reference/plugins/vite.mdx b/docs/src/content/docs/reference/plugins/vite.mdx index c13439f..50e5ca9 100644 --- a/docs/src/content/docs/reference/plugins/vite.mdx +++ b/docs/src/content/docs/reference/plugins/vite.mdx @@ -48,7 +48,7 @@ repPlugin({ - **Injects `