Skip to content
Open
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
97 changes: 97 additions & 0 deletions .github/workflows/tests-pii-ner-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
name: 'PII NER tier E2E (live GGUF, CPU)'

# Runs the real privacy-filter GGUF NER tier end-to-end on CPU — the gap the
# hermetic tests/e2e suite cannot cover (it only exercises the in-process
# pattern tier). Heavy (builds the C++ backend image + downloads a ~2.7 GB
# GGUF), so it is path-filtered on PRs and otherwise runs nightly / on demand.
#
# This drives the container-level harness (tests/e2e-backends) via
# `make test-extra-backend-privacy-filter`: it builds the privacy-filter image,
# downloads the model, loads it on CPU, and asserts byte-correct, UTF-8-aligned
# TokenClassify spans. The complementary HTTP-path specs in tests/e2e
# (e2e_pii_ner_test.go) Skip unless PII_NER_MODEL_GGUF is wired.

on:
workflow_dispatch:
schedule:
- cron: '0 3 * * *'
push:
branches:
- master
paths:
- 'backend/cpp/privacy-filter/**'
- 'backend/Dockerfile.privacy-filter'
- 'core/services/routing/pii/**'
- 'core/services/routing/piidetector/**'
- 'core/backend/token_classify.go'
- 'core/http/endpoints/localai/pii.go'
- 'core/schema/pii.go'
- 'tests/e2e-backends/**'
- 'tests/e2e/e2e_pii_ner_test.go'
- 'tests/e2e/e2e_suite_test.go'
- '.github/workflows/tests-pii-ner-e2e.yml'
pull_request:
paths:
- 'backend/cpp/privacy-filter/**'
- 'backend/Dockerfile.privacy-filter'
- 'core/services/routing/pii/**'
- 'core/services/routing/piidetector/**'
- 'core/backend/token_classify.go'
- 'core/http/endpoints/localai/pii.go'
- 'core/schema/pii.go'
- 'tests/e2e-backends/**'
- 'tests/e2e/e2e_pii_ner_test.go'
- 'tests/e2e/e2e_suite_test.go'
- '.github/workflows/tests-pii-ner-e2e.yml'

concurrency:
group: ci-tests-pii-ner-e2e-${{ github.event.pull_request.number || github.sha }}-${{ github.repository }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
tests-pii-ner-e2e:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.25.x']
steps:
- name: Clone
uses: actions/checkout@v6
with:
submodules: true
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true
sudo docker image prune --all --force || true
df -h
- name: Configure apt mirror on runner
uses: ./.github/actions/configure-apt-mirror
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: false
- name: Proto Dependencies
run: |
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- name: Dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential
# Builds local-ai-backend:privacy-filter, downloads the GGUF, loads it on
# CPU and runs the token_classify capability spec (byte-offset contract).
- name: Run live PII NER backend E2E
run: PATH="$PATH:$HOME/go/bin" make test-extra-backend-privacy-filter
- name: Setup tmate session if tests fail
if: ${{ failure() }}
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
connect-timeout-seconds: 180
limit-access-to-actor: true
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,16 @@ test-extra-backend-llama-cpp-transcription: docker-build-llama-cpp
BACKEND_TEST_CTX_SIZE=2048 \
$(MAKE) test-extra-backend

## privacy-filter: the PII/NER token-classification backend. Exercises the
## TokenClassify RPC and asserts byte-correct, UTF-8-aligned span offsets
## against the openai-privacy-filter multilingual GGUF (CPU-runnable, ~50M
## active params). This is the live-backend coverage for the PII NER tier.
test-extra-backend-privacy-filter: docker-build-privacy-filter
BACKEND_IMAGE=local-ai-backend:privacy-filter \
BACKEND_TEST_MODEL_URL=https://huggingface.co/LocalAI-io/privacy-filter-multilingual-GGUF/resolve/main/privacy-filter-multilingual-f16.gguf \
BACKEND_TEST_CAPS=health,load,token_classify \
$(MAKE) test-extra-backend

## vllm is resolved from a HuggingFace model id (no file download) and
## exercises Predict + streaming + tool-call extraction via the hermes parser.
## Requires a host CPU with the SIMD instructions the prebuilt vllm CPU
Expand Down
10 changes: 4 additions & 6 deletions core/application/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,11 +341,9 @@ func (a *Application) ResolvePIIPolicy(cfg *config.ModelConfig) (enabled bool, d
}
appCfg := a.ApplicationConfig()

if cfg.PII.Enabled != nil {
enabled = *cfg.PII.Enabled
} else {
enabled = cfg.PIIIsEnabled() // backend default (cloud-proxy)
}
// PIIIsEnabled already encodes "explicit pii.enabled wins, else backend
// default (cloud-proxy)" — the single source of that rule.
enabled = cfg.PIIIsEnabled()
if !enabled {
return false, nil
}
Expand All @@ -354,7 +352,7 @@ func (a *Application) ResolvePIIPolicy(cfg *config.ModelConfig) (enabled bool, d
if len(detectors) == 0 {
detectors = append([]string(nil), appCfg.PIIDefaultDetectors...)
}
return enabled, detectors
return true, detectors // enabled is necessarily true past the !enabled guard
}

// PIIPolicyResolver adapts ResolvePIIPolicy to pii.PolicyResolver for
Expand Down
48 changes: 48 additions & 0 deletions core/http/react-ui/e2e/model-config.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,21 @@ test.describe('Model Editor - Interactive Tab', () => {
await expect(page.locator('input[placeholder^="match,"]')).toBeVisible()
})

test('pattern min_len clamps a directly-typed negative to 0', async ({ page }) => {
const searchInput = page.locator('input[placeholder="Search fields to add..."]')
await searchInput.fill('Custom Secret Patterns')
const dropdown = searchInput.locator('..').locator('..')
await dropdown.locator('div', { hasText: 'Custom Secret Patterns' }).first().click()

await page.locator('button', { hasText: 'Add pattern' }).click()
// The number input's min={0} only limits the spinner arrows, not keyboard
// entry; the editor must sanitise a typed negative so a meaningless
// negative length floor never reaches the saved config.
const minLen = page.locator('input[aria-label="Minimum length"]')
await minLen.fill('-5')
await expect(minLen).toHaveValue('0')
})

// Regression: a map-typed field (entity_actions) present in the loaded YAML
// must render WITH its values. flattenConfig used to recurse into the map,
// scattering it across pii_detection.entity_actions.<GROUP> paths that match
Expand Down Expand Up @@ -329,4 +344,37 @@ test.describe('Model Editor - Interactive Tab', () => {
await expect(page.getByText(/block —/i).first()).toBeVisible()
})

// A map cannot hold two values for one key, so renaming a row to an existing
// group must collapse to a single row (Object.fromEntries, last write wins)
// rather than rendering two conflicting rows that silently lose one on save.
test('entity_actions collapses a duplicate group to a single row', async ({ page }) => {
await page.route('**/api/models/edit/ner-model', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
name: 'ner-model',
config: [
'name: ner-model',
'backend: llama-cpp',
'pii_detection:',
' entity_actions:',
' SSN: block',
' EMAIL: mask',
'',
].join('\n'),
}),
})
})

await page.goto('/app/model-editor/ner-model')

const groupInputs = page.locator('input[aria-label="Entity group"]')
await expect(groupInputs).toHaveCount(2)

// Rename the EMAIL row to duplicate SSN; the editor collapses to one SSN row.
await groupInputs.nth(1).fill('SSN')
await expect(groupInputs).toHaveCount(1)
await expect(groupInputs.nth(0)).toHaveValue('SSN')
})

})
13 changes: 12 additions & 1 deletion core/http/react-ui/src/components/PatternListEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,18 @@ export default function PatternListEditor({ value, onChange }) {
min={0}
value={r.min_len || 0}
title="Minimum match length (0 = no floor)"
onChange={e => update(i, { min_len: parseInt(e.target.value, 10) || 0 })}
// min={0} only constrains the spinner, not keyboard entry. Clamp a
// typed negative to 0 (a negative floor is meaningless and would
// disable the length filter). When we clamp, force the DOM value
// too: the resulting 0->0 state change is a no-op, so React's
// controlled input would otherwise keep displaying the rejected
// "-5" even though the saved value is 0.
onChange={e => {
const parsed = parseInt(e.target.value, 10)
const n = Math.max(0, parsed || 0)
if (parsed < 0) e.target.value = String(n)
update(i, { min_len: n })
}}
style={{ width: 80, fontSize: '0.8125rem' }}
aria-label="Minimum length"
/>
Expand Down
2 changes: 1 addition & 1 deletion core/services/routing/piiadapter/openai_completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func applyAnyText(v any, elem int, text string) any {
if elem < 0 {
return text
}
if arr, ok := v.([]any); ok && elem >= 0 && elem < len(arr) {
if arr, ok := v.([]any); ok && elem < len(arr) {
arr[elem] = text
}
return v
Expand Down
7 changes: 4 additions & 3 deletions core/services/routing/piidetector/pattern.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ type patternDetector struct {
// When tracing is enabled it records a pattern_pii BackendTrace so the matches
// (group, byte range, text) show in the Traces UI alongside NER detections.
func (d *patternDetector) Detect(_ context.Context, text string) ([]pii.NEREntity, error) {
tracing := d.appConfig != nil && d.appConfig.EnableTracing
var start time.Time
if d.appConfig != nil && d.appConfig.EnableTracing {
if tracing {
trace.InitBackendTracingIfEnabled(d.appConfig.TracingMaxItems, d.appConfig.TracingMaxBodyBytes)
start = time.Now()
}
Expand All @@ -50,12 +51,12 @@ func (d *patternDetector) Detect(_ context.Context, text string) ([]pii.NEREntit
var traceEnts []backend.TokenEntity
for _, mt := range matches {
out = append(out, pii.NEREntity{Group: mt.Group, Start: mt.Start, End: mt.End, Score: 1.0, Text: mt.Text})
if d.appConfig != nil && d.appConfig.EnableTracing {
if tracing {
traceEnts = append(traceEnts, backend.TokenEntity{Group: mt.Group, Start: mt.Start, End: mt.End, Score: 1.0, Text: mt.Text})
}
}

if d.appConfig != nil && d.appConfig.EnableTracing {
if tracing {
trace.RecordBackendTrace(patternPIITrace(d.modelName, text, traceEnts, start))
}
return out, nil
Expand Down
14 changes: 10 additions & 4 deletions core/services/routing/piipattern/grammar.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@ const (
// credential shape, small enough that the compiled program stays tiny.
MaxPatternLen = 256
// MaxQuantifier caps an explicit {n,m} upper bound. RE2 expands a bounded
// repeat into that many copies, so an uncapped {0,1000000} would blow up
// the compiled program's memory. Unbounded {n,} (no upper) is a loop, not
// an expansion, and is allowed.
MaxQuantifier = 4096
// repeat into that many copies, so a large bound inflates the compiled
// program. Go's regexp/syntax independently rejects any bound above 1000
// at Parse time, so this cap MUST stay strictly below 1000 to be a live
// guard rather than dead code shadowed by the parser: a bound in
// (MaxQuantifier, 1000] reaches walk and is rejected here with an
// actionable error, while >1000 is caught earlier by Parse. 512 is far
// larger than any real credential token yet keeps the guard meaningful and
// is defence in depth should the stdlib cap ever rise. Unbounded {n,} (no
// upper) is a loop, not an expansion, and is allowed.
MaxQuantifier = 512
// MaxAlternation caps the arms of a single `a|b|c` alternation.
MaxAlternation = 64
// MaxAST bounds recursion depth so a pathologically nested pattern can't
Expand Down
40 changes: 40 additions & 0 deletions core/services/routing/piipattern/piipattern_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package piipattern

import (
"fmt"
"strings"
"testing"

Expand Down Expand Up @@ -36,6 +37,45 @@ var _ = Describe("ValidatePattern", func() {
)
})

var _ = Describe("MaxQuantifier guard (must stay live, not dead code)", func() {
// Go's regexp/syntax hard-caps repeat bounds at 1000 and rejects anything
// larger at Parse time, before walk() runs. So the walk() {n,m} guard only
// fires for bounds in (MaxQuantifier, 1000]; if MaxQuantifier ever creeps
// to >= 1000 the guard becomes unreachable dead code. These specs pin the
// relationship and prove the guard is the binding constraint in that band.
const stdlibRepeatCap = 1000

It("is strictly below the stdlib repeat cap so the guard is reachable", func() {
Expect(MaxQuantifier).To(BeNumerically("<", stdlibRepeatCap),
"MaxQuantifier must be < %d or walk()'s {n,m} guard is dead code (Parse rejects larger bounds first)", stdlibRepeatCap)
})

It("accepts a bound at exactly MaxQuantifier", func() {
Expect(ValidatePattern(fmt.Sprintf(`sk-ant-[A-Za-z0-9]{%d}`, MaxQuantifier))).To(Succeed())
})

It("rejects a bound just above MaxQuantifier with our actionable error (proves the guard runs)", func() {
// MaxQuantifier+1 is still parseable (<= stdlib cap), so it reaches
// walk(), where our guard — not the parser — rejects it.
err := ValidatePattern(fmt.Sprintf(`sk-ant-[A-Za-z0-9]{%d}`, MaxQuantifier+1))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("bound is too large"),
"a bound in (MaxQuantifier, stdlib cap] must be rejected by walk(), not the parser")
})

It("rejects an unbounded {n,} whose lower bound exceeds MaxQuantifier", func() {
err := ValidatePattern(fmt.Sprintf(`sk-ant-[A-Za-z0-9]{%d,}`, MaxQuantifier+1))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("bound is too large"))
})

It("still fails closed above the stdlib cap (Parse rejects before walk)", func() {
// >1000: caught by syntax.Parse; the message is the parser's, but it
// still fails closed — defence in depth.
Expect(ValidatePattern(fmt.Sprintf(`sk-ant-[A-Za-z0-9]{%d}`, stdlibRepeatCap+1))).NotTo(Succeed())
})
})

var _ = Describe("Compile", func() {
It("compiles a valid pattern with leftmost-longest semantics", func() {
re, err := Compile(`sk-ant-[A-Za-z0-9_-]{4,}`)
Expand Down
Loading
Loading