Skip to content

Commit 2d06410

Browse files
fix(guardrails): reconcile startup and dashboard behavior
1 parent 0544105 commit 2d06410

21 files changed

Lines changed: 382 additions & 70 deletions

.env.template

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@
130130
# to streaming requests for OpenAI-compatible providers
131131
# ENFORCE_RETURNING_USAGE_DATA=true
132132

133+
# Enable/disable guardrails globally (default: false)
134+
# When enabled, configured guardrails can run for workflows that reference them
135+
# GUARDRAILS_ENABLED=false
136+
133137
# Guardrails for inline batch processing (default: false)
134138
# When true, guardrails are applied to inline /v1/batches request items
135139
# (e.g. /v1/chat/completions and /v1/responses items).

internal/admin/dashboard/static/js/dashboard.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,8 @@ function dashboard() {
205205

206206
guardrailsPageVisible() {
207207
return typeof this.executionPlanRuntimeBooleanFlag === 'function'
208-
? this.executionPlanRuntimeBooleanFlag('GUARDRAILS_ENABLED', false)
209-
: false;
208+
? this.executionPlanRuntimeBooleanFlag('GUARDRAILS_ENABLED', true)
209+
: true;
210210
},
211211

212212
setTheme(t) {

internal/admin/dashboard/static/js/modules/dashboard-layout.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ test('dashboard pages reuse a shared auth banner template', () => {
8787
);
8888

8989
const authBannerCalls = indexTemplate.match(/{{template "auth-banner" \.}}/g) || [];
90-
assert.equal(authBannerCalls.length, 6);
90+
assert.equal(authBannerCalls.length, 7);
91+
assert.match(indexTemplate, /<div x-show="page==='guardrails'">[\s\S]*{{template "auth-banner" \.}}/);
9192
assert.doesNotMatch(
9293
indexTemplate,
9394
/<div class="alert alert-warning" x-show="authError">[\s\S]*Authentication required\. Enter your API key in the sidebar to view data\.[\s\S]*<\/div>/
@@ -113,6 +114,7 @@ test('workflow guardrail warning links directly to the top-level guardrails page
113114
assert.match(indexTemplate, /No named guardrails are currently registered on this deployment\./);
114115
assert.match(indexTemplate, /class="alert alert-warning alert-inline-actions" x-show="guardrailRefs\.length === 0"/);
115116
assert.match(indexTemplate, /@click="navigate\('guardrails'\)">Open Guardrails<\/button>/);
117+
assert.match(indexTemplate, /id="guardrail-filter"[^>]*aria-label="Guardrail filter"[^>]*x-model="guardrailFilter"/);
116118
});
117119

118120
test('usage and audit pages reuse a shared pagination template', () => {

internal/admin/dashboard/static/js/modules/guardrails.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ test('normalizeGuardrailConfig merges stored config over type defaults', () => {
5252
assert.equal(JSON.stringify(config), JSON.stringify({ mode: 'inject', content: 'be careful' }));
5353
});
5454

55+
test('normalizeGuardrailConfig returns the input config for unknown types', () => {
56+
const module = createGuardrailsModule();
57+
module.guardrailTypes = [
58+
{
59+
type: 'system_prompt',
60+
defaults: { mode: 'inject', content: '' },
61+
fields: []
62+
}
63+
];
64+
65+
const config = module.normalizeGuardrailConfig({ content: 'test' }, 'unknown_type');
66+
67+
assert.equal(JSON.stringify(config), JSON.stringify({ content: 'test' }));
68+
});
69+
5570
test('filteredGuardrails matches user_path values', () => {
5671
const module = createGuardrailsModule();
5772
module.guardrails = [

internal/admin/dashboard/templates/index.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -287,14 +287,16 @@ <h3>Guardrail Library</h3>
287287
</div>
288288
</div>
289289

290-
<div class="alert alert-warning" x-show="!guardrailsRuntimeEnabled() && !authError">
290+
{{template "auth-banner" .}}
291+
292+
<div class="alert alert-warning" x-show="!guardrailsRuntimeEnabled()">
291293
Runtime guardrail execution is currently off because <code>GUARDRAILS_ENABLED</code> is disabled. You can still manage definitions here.
292294
</div>
293-
<div class="alert alert-warning" x-show="!guardrailsAvailable && !authError">
295+
<div class="alert alert-warning" x-show="!authError && !guardrailsAvailable">
294296
Guardrails feature is unavailable.
295297
</div>
296-
<div class="alert alert-warning" x-show="guardrailError && !authError" x-text="guardrailError"></div>
297-
<div class="alert alert-success" x-show="guardrailNotice && !guardrailError" x-text="guardrailNotice"></div>
298+
<div class="alert alert-warning" x-show="!authError && guardrailError" x-text="guardrailError"></div>
299+
<div class="alert alert-success" x-show="!authError && guardrailNotice && !guardrailError" x-text="guardrailNotice"></div>
298300

299301
<div class="settings-guardrails-layout" :class="{ 'is-editor-open': guardrailFormOpen }">
300302
<section class="settings-panel settings-guardrails-list">
@@ -308,7 +310,7 @@ <h3>Instances</h3>
308310

309311
<div class="table-toolbar" x-show="guardrailsAvailable">
310312
<div class="table-toolbar-main">
311-
<input type="text" class="filter-input" placeholder="Filter by name, type, user path, summary..." x-model="guardrailFilter">
313+
<input type="text" id="guardrail-filter" class="filter-input" placeholder="Filter by name, type, user path, summary..." aria-label="Guardrail filter" x-model="guardrailFilter">
312314
</div>
313315
</div>
314316

internal/admin/handler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,9 @@ func (h *Handler) activeWorkflowGuardrailReferences(ctx context.Context, name st
11731173

11741174
references := make([]string, 0)
11751175
for _, view := range views {
1176+
if !view.Payload.Features.Guardrails {
1177+
continue
1178+
}
11761179
for _, step := range view.Payload.Guardrails {
11771180
if strings.TrimSpace(step.Ref) != name {
11781181
continue

internal/admin/handler_guardrails_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,57 @@ func TestDeleteGuardrailRejectsActiveWorkflowReference(t *testing.T) {
225225
t.Fatalf("error message = %q, want active workflow reference", envelope.Error.Message)
226226
}
227227
}
228+
229+
func TestDeleteGuardrailIgnoresDisabledWorkflowGuardrailRefs(t *testing.T) {
230+
guardrailService := newGuardrailService(t, guardrails.Definition{
231+
Name: "policy-system",
232+
Type: "system_prompt",
233+
Config: rawGuardrailConfig(t, map[string]any{
234+
"mode": "inject",
235+
"content": "be precise",
236+
}),
237+
})
238+
planStore := &executionPlanTestStore{
239+
versions: []executionplans.Version{
240+
{
241+
ID: "global-plan",
242+
Scope: executionplans.Scope{},
243+
ScopeKey: "global",
244+
Version: 1,
245+
Active: true,
246+
Name: "global",
247+
Payload: executionplans.Payload{
248+
SchemaVersion: 1,
249+
Features: executionplans.FeatureFlags{Cache: true, Audit: true, Usage: true, Guardrails: false},
250+
Guardrails: []executionplans.GuardrailStep{{Ref: "policy-system", Step: 10}},
251+
},
252+
PlanHash: "hash-global",
253+
},
254+
},
255+
}
256+
planService, err := executionplans.NewService(planStore, executionplans.NewCompiler(guardrailService))
257+
if err != nil {
258+
t.Fatalf("executionplans.NewService() error = %v", err)
259+
}
260+
if err := planService.Refresh(context.Background()); err != nil {
261+
t.Fatalf("planService.Refresh() error = %v", err)
262+
}
263+
264+
h := NewHandler(nil, nil, WithGuardrailService(guardrailService), WithExecutionPlans(planService))
265+
e := echo.New()
266+
req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/guardrails/policy-system", nil)
267+
rec := httptest.NewRecorder()
268+
c := e.NewContext(req, rec)
269+
c.SetPath("/admin/api/v1/guardrails/:name")
270+
c.SetPathValues(echo.PathValues{{Name: "name", Value: "policy-system"}})
271+
272+
if err := h.DeleteGuardrail(c); err != nil {
273+
t.Fatalf("DeleteGuardrail() error = %v", err)
274+
}
275+
if rec.Code != http.StatusNoContent {
276+
t.Fatalf("status = %d, want 204", rec.Code)
277+
}
278+
if _, ok := h.guardrailDefs.Get("policy-system"); ok {
279+
t.Fatal("Get(policy-system) = present, want deleted guardrail")
280+
}
281+
}

internal/app/app.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,12 @@ func New(ctx context.Context, cfg Config) (*App, error) {
195195
}
196196
return nil, fmt.Errorf("failed to prepare guardrail definitions: %w", err)
197197
}
198-
if err := guardrailResult.Service.EnsureSeedDefinitions(ctx, seedGuardrails); err != nil {
198+
if err := guardrailResult.Service.UpsertDefinitions(ctx, seedGuardrails); err != nil {
199199
closeErr := errors.Join(app.guardrails.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close())
200200
if closeErr != nil {
201-
return nil, fmt.Errorf("failed to seed guardrails: %w (also: close error: %v)", err, closeErr)
201+
return nil, fmt.Errorf("failed to upsert guardrails: %w (also: close error: %v)", err, closeErr)
202202
}
203-
return nil, fmt.Errorf("failed to seed guardrails: %w", err)
203+
return nil, fmt.Errorf("failed to upsert guardrails: %w", err)
204204
}
205205

206206
// Build runtime execution dependencies. Policy is passed explicitly into the
@@ -225,7 +225,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
225225
}
226226
return nil, fmt.Errorf("failed to initialize execution plans: %w", err)
227227
}
228-
defaultExecutionPlan := defaultExecutionPlanInput(appCfg, guardrailResult.Service.Names())
228+
defaultExecutionPlan := defaultExecutionPlanInput(appCfg, guardrailResult.Service.Names(), seedGuardrails)
229229
if err := executionPlanResult.Service.EnsureDefaultGlobal(ctx, defaultExecutionPlan); err != nil {
230230
closeErr := errors.Join(executionPlanResult.Close(), app.guardrails.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close())
231231
if closeErr != nil {
@@ -731,7 +731,7 @@ func configGuardrailDefinitions(cfg config.GuardrailsConfig) ([]guardrails.Defin
731731
return definitions, nil
732732
}
733733

734-
func defaultExecutionPlanInput(cfg *config.Config, availableGuardrails []string) executionplans.CreateInput {
734+
func defaultExecutionPlanInput(cfg *config.Config, availableGuardrails []string, configuredGuardrails []guardrails.Definition) executionplans.CreateInput {
735735
fallbackEnabled := fallbackFeatureEnabledGlobally(cfg)
736736
payload := executionplans.Payload{
737737
SchemaVersion: 1,
@@ -746,6 +746,13 @@ func defaultExecutionPlanInput(cfg *config.Config, availableGuardrails []string)
746746
for _, name := range availableGuardrails {
747747
available[strings.TrimSpace(name)] = struct{}{}
748748
}
749+
for _, definition := range configuredGuardrails {
750+
name := strings.TrimSpace(definition.Name)
751+
if name == "" {
752+
continue
753+
}
754+
available[name] = struct{}{}
755+
}
749756
if cfg.Guardrails.Enabled && len(cfg.Guardrails.Rules) > 0 {
750757
payload.Guardrails = make([]executionplans.GuardrailStep, 0, len(cfg.Guardrails.Rules))
751758
for _, rule := range cfg.Guardrails.Rules {
@@ -765,8 +772,8 @@ func defaultExecutionPlanInput(cfg *config.Config, availableGuardrails []string)
765772
return executionplans.CreateInput{
766773
Scope: executionplans.Scope{},
767774
Activate: true,
768-
Name: "default-global",
769-
Description: "Bootstrapped from runtime configuration",
775+
Name: executionplans.ManagedDefaultGlobalName,
776+
Description: executionplans.ManagedDefaultGlobalDescription,
770777
Payload: payload,
771778
}
772779
}

internal/app/app_test.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"gomodel/config"
77
"gomodel/internal/admin"
8+
"gomodel/internal/guardrails"
89
)
910

1011
func TestRuntimeExecutionFeatureCaps_EnableFallbackFromOverride(t *testing.T) {
@@ -30,7 +31,7 @@ func TestDefaultExecutionPlanInput_SetsFallbackFeature(t *testing.T) {
3031
},
3132
}
3233

33-
input := defaultExecutionPlanInput(cfg, nil)
34+
input := defaultExecutionPlanInput(cfg, nil, nil)
3435
if input.Payload.Features.Fallback == nil {
3536
t.Fatal("defaultExecutionPlanInput().Payload.Features.Fallback = nil, want non-nil")
3637
}
@@ -39,6 +40,35 @@ func TestDefaultExecutionPlanInput_SetsFallbackFeature(t *testing.T) {
3940
}
4041
}
4142

43+
func TestDefaultExecutionPlanInput_IncludesConfiguredGuardrailsMissingFromLoadedCatalog(t *testing.T) {
44+
cfg := &config.Config{
45+
Guardrails: config.GuardrailsConfig{
46+
Enabled: true,
47+
Rules: []config.GuardrailRuleConfig{
48+
{
49+
Name: "policy-system",
50+
Type: "system_prompt",
51+
Order: 10,
52+
},
53+
},
54+
},
55+
}
56+
57+
input := defaultExecutionPlanInput(cfg, nil, []guardrails.Definition{
58+
{Name: "policy-system", Type: "system_prompt"},
59+
})
60+
61+
if !input.Payload.Features.Guardrails {
62+
t.Fatal("defaultExecutionPlanInput().Payload.Features.Guardrails = false, want true")
63+
}
64+
if len(input.Payload.Guardrails) != 1 {
65+
t.Fatalf("len(defaultExecutionPlanInput().Payload.Guardrails) = %d, want 1", len(input.Payload.Guardrails))
66+
}
67+
if got := input.Payload.Guardrails[0].Ref; got != "policy-system" {
68+
t.Fatalf("defaultExecutionPlanInput().Payload.Guardrails[0].Ref = %q, want policy-system", got)
69+
}
70+
}
71+
4272
func TestConfigGuardrailDefinitions_DisabledIgnoresInvalidRules(t *testing.T) {
4373
definitions, err := configGuardrailDefinitions(config.GuardrailsConfig{
4474
Enabled: false,

internal/executionplans/compiler.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package executionplans
22

33
import (
4-
"fmt"
4+
"net/http"
55

66
"gomodel/internal/core"
77
"gomodel/internal/guardrails"
@@ -69,7 +69,7 @@ func (c *compiler) compileGuardrails(steps []guardrails.StepReference) (*guardra
6969
return nil, "", nil
7070
}
7171
if c == nil || c.registry == nil {
72-
return nil, "", fmt.Errorf("guardrails are enabled but no guardrail registry is configured")
72+
return nil, "", core.NewProviderError("", http.StatusBadGateway, "guardrails are enabled but no guardrail registry is configured", nil)
7373
}
7474
return c.registry.BuildPipeline(steps)
7575
}

0 commit comments

Comments
 (0)