Skip to content

feat(policy): Residenz-Policy an den Guard hängen, den jede Anfrage passiert - #25

Merged
Baldri merged 3 commits into
mainfrom
claude/session/feat/mingly-policy-routing
Aug 27, 2026
Merged

feat(policy): Residenz-Policy an den Guard hängen, den jede Anfrage passiert#25
Baldri merged 3 commits into
mainfrom
claude/session/feat/mingly-policy-routing

Conversation

@Baldri

@Baldri Baldri commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Was

Die Residenz-Policy aus #23 ist jetzt im Produktionspfad — verkettet mit dem Wächter, den Mingly bereits hatte. Dazu zwei Vorarbeiten, die sie erst wirksam machen.

Warum — eine Korrektur vorweg

Mingly routete schon nach Schutzbedarf. DataClassifier entscheidet über Vertrauensstufen und läuft seit längerem auf fünf Pfaden: Chat-IPC, Agent, Vergleich, Service-Layer, HTTP. Die frühere Einschätzung, die App route gar nicht nach Schutzbedarf, war falsch — sie stützte sich darauf, dass routeWithPolicy keinen Aufrufer hat, ohne zu prüfen, ob ein anderer Mechanismus die Aufgabe erfüllt.

Der Policy-Kern war damit kein fehlender Aufrufer, sondern ein zweites System für dieselbe Frage. Dieser PR verkettet sie, statt eines durch das andere zu ersetzen.

Die drei Commits

1. on-device als eigener Residenzwert. ollama trug residency: 'CH'. Fürs Routing richtig — Ausführung auf dem eigenen Gerät ist der strengste Fall —, fürs Audit-Log falsch: dort stand «Schweiz» für eine Inferenz, die überall laufen kann. Beide Regeln in DEFAULT_POLICY lassen den neuen Wert zu; no-unverified-endpoints muss ihn zulassen, sonst würde das lokale Modell bei mittlerem Schutzbedarf neu ausgeschlossen. DpaStatus bekommt not-applicable: auf dem eigenen Gerät verarbeitet kein Dritter etwas, es gibt also keinen Vertrag, den man unterschrieben haben könnte.

2. .env wird geladen. Nichts im Repo tat das, also war INFOMANIAK_PRODUCT_ID in einer gepackten App nie gesetzt. Handgeschrieben statt Abhängigkeit; zwei Eigenschaften sind durch Tests festgenagelt: eine bereits gesetzte Variable gewinnt immer, und eine gepackte App liest ausschliesslich ihr eigenes userData-Verzeichnis, nie das Arbeitsverzeichnis.

3. Verkettung — und der Fund, der sie erst wirksam macht. PROVIDER_TRUST nennt fünf Anbieter; alles andere fiel auf PUBLIC, die niedrigste Stufe. Gemessen am 2026-08-27:

anthropic    allowed=false   ollama       allowed=true
infomaniak   allowed=false   irgendwas    allowed=false

Der verifizierte Schweizer Endpunkt wurde also behandelt wie ein erfundener Anbietername und hätte nie die vertraulichen Inhalte bekommen, für die er existiert. Vertrauen wird jetzt zweistufig aufgelöst: ein expliziter Tabelleneintrag gewinnt immer (so bleibt Google unter Anthropic/OpenAI), sonst verdient ein registrierter Anbieter Vertrauen aus der von uns verifizierten Herkunft. Ein mandantenseitig eingetragener Endpunkt verdient nichts — registerTenant erzwingt residency: 'unknown' (I2). Schweizer Boden ohne unterzeichneten AVV ebenfalls nicht.

Tests

npm test1403 passed | 29 skipped (93 Dateien), Exit 0 · npm run typecheck → Exit 0

Bestehende data-classifier-Tests unverändert und grün — der Auflöser wird injiziert, die Voreinstellungen verhalten sich wie zuvor.

Sabotagen, jede tatsächlich rot gesehen:

Sabotage Ergebnis
Residenz-Verkettung aus den Deps entfernen Jurisdiktionstest rot
Vertrauen aus der geschlossenen Tabelle auflösen Schweizer Endpunkt wieder abgewiesen
ollama mit US-Herkunft registrieren Vertrauenstabelle sagt ja, Policy sagt nein — geblockt

Die erste Sabotage war aufschlussreich: sie machte zunächst keinen Test rot. Grund ist inhaltlich — weil Vertrauen aus der Residenz abgeleitet wird, stimmen beide Schichten meist überein. Sie können nur an einer Stelle auseinandergehen: wenn ein expliziter Vertrauenseintrag einen Anbieter hochstuft, dessen Standort die Policy ablehnt. Genau das ist der Wert der Verkettung, und genau dafür gibt es jetzt einen Test.

Review-Punkte

Die Residenzschicht ist heute weitgehend deckungsgleich mit der Vertrauensschicht und blockt nur den Fall oben. Ihr eigentlicher Beitrag ist der Audit-Eintrag und ein versionierter, deklarativer Regelsatz, der ohne Codeänderung verschoben werden kann.

bySource bleibt auf diesem Pfad null. Die Trefferzahlen je Detektorschicht (I3) stammen vom Klassifikator des Policy-Kerns, nicht vom Vertrauensklassifikator. Der Kommentar sagt das; erfunden wird nichts.

Ein Vorschlag wird nur gemacht, wenn er auch der Vertrauensprüfung standhält. guardDispatch wechselt zu suggestedProvider, ohne die Vertrauensprüfung zu wiederholen — ohne dieses Prädikat wäre die Verkettung ein Weg an dem Wächter vorbei, an den sie gehängt wurde.

.env ist der Entwickler- und Power-User-Pfad. Die dauerhafte Heimat der Produkt-ID sind die Einstellungen (EncryptedStore, keychain-manager) — eine Datei neben einer gepackten App ist nichts, was die App anbietet.

🤖 Generated with Claude Code

Baldri and others added 3 commits August 27, 2026 17:25
`ollama` was registered with `residency: 'CH'`. For the routing decision that
was right — inference on the user's own machine is the strictest case and must
be able to serve the most sensitive requests. For the audit log it was not:
the row then asserts «Switzerland» for inference that happens wherever the
device happens to be, in the document a customer hands to a supervisory
authority.

`Residency` gains `on-device`, and both rules in DEFAULT_POLICY admit it —
`sensitive-stays-ch` because local execution satisfies any residency
requirement, `no-unverified-endpoints` because leaving it out would newly
EXCLUDE the local model at medium sensitivity, which nothing intends.

`DpaStatus` gains `not-applicable` for the same reason: no third party
processes anything on the user's own machine, so there is no agreement to have
signed. That is a different statement from `none`, which means one is missing.

Routing behaviour is unchanged. What changes is what the audit trail claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing in the app read a .env file. In a packaged, GUI-launched build
INFOMANIAK_PRODUCT_ID was therefore never set, seedSwissProviders registered
nothing, and the Swiss endpoint was simply absent — the policy rule that keeps
sensitive requests in Switzerland would have been satisfied by whatever else
claimed Swiss residency.

Hand-rolled rather than a dependency: the needed surface is KEY=value with
comments and blank lines. Anything richer would be a reason to reach for a
library instead of growing the file.

Two properties are pinned by tests because both decide whether the app's
configuration is predictable from outside it:

- An already-set variable always wins. A .env left behind by an earlier
  install must not redirect a process started with an explicit value.
- A packaged app reads ONLY its own userData directory, never the working
  directory — that is whatever the OS launched it from.

This is the developer and power-user path. The durable home for end-user
configuration is the settings store; a file next to a packaged app is
something a user has to know about, not something the app offers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… passes

Mingly already routed by sensitivity. DataClassifier decides whether a
provider is TRUSTED enough for the content, and guardDispatch has enforced
that on all five paths — chat IPC, agent, comparison, service layer, HTTP.
The policy core added a second, independent question: does the provider sit
in an acceptable JURISDICTION? Both must now pass.

They are kept as separate axes on purpose. The trust levels distinguish
providers the registry cannot — Google is rated below Anthropic and OpenAI
although all three are `residency: 'US'` — and residency expresses something
no trust level can. Folding either into the other would drop a distinction
the product currently makes.

Chaining alone was not enough. PROVIDER_TRUST names five providers and
everything else fell to PUBLIC, the LOWEST level, so a Swiss endpoint we had
verified ourselves was treated exactly like an invented provider name and
was refused the confidential content it exists to handle (measured
2026-08-27: `infomaniak` and `irgendwas` produced identical decisions). Trust
is now resolved in two steps: an explicit table entry always wins, otherwise
a registered provider earns trust from the origin WE verified. A
tenant-registered endpoint earns nothing — registerTenant forces
`residency: 'unknown'` (I2), so a tenant cannot talk itself into confidential
content. Swiss soil without a signed DPA earns nothing either.

The resolver is injected into DataClassifier rather than imported, because
importing the registry there would close a module cycle. Defaults keep the
old behaviour, so the existing classifier tests are untouched and green.

Each decision is written to the activity log — level, applied policy version,
permitted set, chosen provider, residency. Never the message text. The
per-detector-layer counts stay zero on this path and say so in a comment:
they belong to the policy core's own classifier, not to this bridge.

Three sabotages, each verified to go red:
- remove the residency chaining -> the jurisdiction test fails
- resolve trust from the closed table -> the Swiss endpoint is refused again
- (unit level) suggest an untrusted alternative -> blocked by the isTrusted
  predicate, which exists because guardDispatch switches to a suggestion
  WITHOUT re-running the trust guard

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Baldri
Baldri merged commit 6d8232e into main Aug 27, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant