trimgate 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
trimgate/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """TrimGate: deterministic, test-time auditor for RAG access-control trimming.
2
+
3
+ v1 verdict is binary PASS or LEAK: for every (identity, probe) the set of
4
+ chunk-IDs a RAG pipeline surfaces must be a subset of that identity's authorized
5
+ chunk-set, derived from the access-matrix. Zero LLM in the trust path.
6
+
7
+ See the reconciled v1 contract (handoff doc, section 0) for the normative shapes.
8
+ """
9
+
10
+ __version__ = "0.0.1"
trimgate/adapter.py ADDED
@@ -0,0 +1,63 @@
1
+ """The adapter contract: the single seam between TrimGate and a host RAG system.
2
+
3
+ Per the reconciled v1 contract (section 0): one REQUIRED method `retrieve`, plus
4
+ two OPTIONAL methods `fetch_resource` and `list_resources`, all returning
5
+ chunk-IDs. The optional methods are detected at runtime (a probe that needs an
6
+ unimplemented one is skipped and logged, never silently passed), so the base
7
+ protocol declares only the required method.
8
+
9
+ The adapter is the only impure boundary. It may do I/O and be non-deterministic;
10
+ TrimGate judges exactly what it returned. A genuine backend failure should raise
11
+ AdapterError, which TrimGate records fail-closed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ from collections.abc import Sequence
18
+ from typing import Protocol, cast, runtime_checkable
19
+
20
+ Identity = str # access-matrix key
21
+
22
+ # Optional adapter method names, detected via hasattr by the engine.
23
+ FETCH_RESOURCE = "fetch_resource"
24
+ LIST_RESOURCES = "list_resources"
25
+
26
+
27
+ @runtime_checkable
28
+ class RagAdapter(Protocol):
29
+ def retrieve(self, query: str, identity: Identity) -> Sequence[str]:
30
+ """Return the chunk-IDs the host retriever would surface for (query, identity)."""
31
+ ...
32
+
33
+
34
+ class AdapterError(Exception):
35
+ """Genuine backend failure (timeout, auth error). Recorded fail-closed."""
36
+
37
+
38
+ def validate_adapter(adapter: object) -> RagAdapter:
39
+ """Fail loudly if the object cannot serve as an adapter.
40
+
41
+ runtime_checkable Protocols only check attribute presence, so a `retrieve`
42
+ that is a string or has the wrong arity would pass isinstance and surface
43
+ later as an opaque error. This checks the seam explicitly at resolve time.
44
+ """
45
+ retrieve = getattr(adapter, "retrieve", None)
46
+ if not callable(retrieve):
47
+ raise TypeError(
48
+ f"adapter {type(adapter).__name__!r} has no callable retrieve(query, identity)"
49
+ )
50
+ return cast("RagAdapter", adapter)
51
+
52
+
53
+ def resolve_adapter(spec: str) -> RagAdapter:
54
+ """Resolve a MODULE:ATTR import path to an adapter instance.
55
+
56
+ ATTR may be a class (instantiated no-args) or a factory (called); an already
57
+ constructed instance is used as-is.
58
+ """
59
+ module_name, sep, attr = spec.partition(":")
60
+ if not sep or not attr:
61
+ raise ValueError(f"adapter must be MODULE:ATTR, got {spec!r}")
62
+ obj = getattr(importlib.import_module(module_name), attr)
63
+ return validate_adapter(obj() if callable(obj) else obj)
@@ -0,0 +1 @@
1
+ """Concrete TrimGate adapters for real RAG backends."""
@@ -0,0 +1,113 @@
1
+ """Azure AI Search + Entra reference adapter.
2
+
3
+ Reproduces the production security-trimming pattern: resolve the identity to its
4
+ Entra group ids, build the OData intersection filter against the document
5
+ `accessGroups`-style field, and run the search so only authorized chunks come
6
+ back. The chunk-IDs returned are exactly what the host would feed the LLM, which
7
+ is what TrimGate then audits against the access-matrix.
8
+
9
+ The adapter is duck-typed against the search client (no azure-sdk import at module
10
+ load), so it can be unit-tested with a fake client and runs in CI without the
11
+ optional `trimgate[azure]` dependency. `make_search_client` lazily imports the
12
+ SDK only when a real client is actually constructed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Callable, Iterable, Mapping, Sequence
19
+ from typing import TYPE_CHECKING, Any, Protocol, cast
20
+
21
+ DEFAULT_GROUP_FIELD = "authorized_principals"
22
+ DEFAULT_CHUNK_ID_FIELD = "chunk_id"
23
+ # Auditing needs a large top: a small top-k can rank an unauthorized chunk out of
24
+ # the observed window and hide a real leak. For a sound audit, `top` must be at
25
+ # least the number of chunks that could pass the (possibly over-broad) filter.
26
+ DEFAULT_AUDIT_TOP = 1000
27
+
28
+ # A group id must not contain characters that could break out of the OData
29
+ # literal, split a delimited value list, or pad/mismatch an ACL. Entra object ids
30
+ # are GUIDs and claim-derived principals look like 'clearance:secret', so this is
31
+ # permissive for real ids while rejecting commas, quotes, whitespace, and control.
32
+ _UNSAFE_GROUP = re.compile(r"[,'\"\s\x00-\x1f]")
33
+ # The field name is interpolated raw into the filter, so it must be a plain OData
34
+ # identifier -- never a caller-supplied expression like "true or x".
35
+ _FIELD_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
36
+
37
+ if TYPE_CHECKING:
38
+ from azure.core.credentials import AzureKeyCredential, TokenCredential
39
+
40
+ from trimgate.adapter import Identity
41
+
42
+
43
+ def build_security_filter(group_field: str, groups: Sequence[str]) -> str:
44
+ """Build the OData security-trimming filter for a Collection(Edm.String) field.
45
+
46
+ Matches documents whose `group_field` shares at least one element with the
47
+ caller's groups, as ORed per-group `eq` clauses (no delimiter to collide with,
48
+ unlike search.in). Fails closed on an empty group set or an unsafe group id.
49
+ """
50
+ if not _FIELD_NAME.match(group_field):
51
+ raise ValueError(f"unsafe OData field name: {group_field!r}")
52
+ if not groups:
53
+ raise ValueError("build_security_filter requires at least one group (fail-closed)")
54
+ for group in groups:
55
+ if not group or _UNSAFE_GROUP.search(group):
56
+ raise ValueError(f"unsafe group id for OData filter: {group!r}")
57
+ return " or ".join(f"{group_field}/any(g: g eq '{group}')" for group in groups)
58
+
59
+
60
+ class SearchClientLike(Protocol):
61
+ def search(
62
+ self, *, search_text: str, top: int, filter: str, select: list[str]
63
+ ) -> Iterable[Mapping[str, Any]]: ...
64
+
65
+
66
+ class AzureSearchAdapter:
67
+ def __init__(
68
+ self,
69
+ client: SearchClientLike,
70
+ group_resolver: Callable[[str], Sequence[str]],
71
+ *,
72
+ group_field: str = DEFAULT_GROUP_FIELD,
73
+ chunk_id_field: str = DEFAULT_CHUNK_ID_FIELD,
74
+ top: int = DEFAULT_AUDIT_TOP,
75
+ ) -> None:
76
+ self._client = client
77
+ self._group_resolver = group_resolver
78
+ self._group_field = group_field
79
+ self._chunk_id_field = chunk_id_field
80
+ self._top = top
81
+
82
+ def retrieve(self, query: str, identity: Identity) -> list[str]:
83
+ groups = list(self._group_resolver(identity))
84
+ if not groups:
85
+ # No groups -> nothing group-gated is authorized. Fail closed without
86
+ # issuing a query (an empty filter's semantics are backend-dependent).
87
+ return []
88
+ filter_expr = build_security_filter(self._group_field, groups)
89
+ results = self._client.search(
90
+ search_text=query,
91
+ top=self._top,
92
+ filter=filter_expr,
93
+ select=[self._chunk_id_field],
94
+ )
95
+ return [str(doc[self._chunk_id_field]) for doc in results]
96
+
97
+
98
+ def make_search_client(
99
+ endpoint: str,
100
+ index_name: str,
101
+ credential: AzureKeyCredential | TokenCredential | None = None,
102
+ ) -> SearchClientLike:
103
+ """Construct a real Azure AI Search client (requires `trimgate[azure]`).
104
+
105
+ Defaults to DefaultAzureCredential (managed identity), aligning with a secure
106
+ Azure posture. Imported lazily so this module loads without the SDK.
107
+ """
108
+ from azure.identity import DefaultAzureCredential
109
+ from azure.search.documents import SearchClient
110
+
111
+ cred = credential or DefaultAzureCredential()
112
+ client = SearchClient(endpoint=endpoint, index_name=index_name, credential=cred)
113
+ return cast("SearchClientLike", client)
trimgate/cli.py ADDED
@@ -0,0 +1,223 @@
1
+ """The `trimgate` command-line interface.
2
+
3
+ `trimgate run` loads the access-matrix, labeled corpus, and probeset, resolves the
4
+ adapter (MODULE:ATTR, instantiated/called if callable), runs the deterministic
5
+ probe runner, prints a human-readable summary, and exits non-zero on any leak so
6
+ CI can gate on it. Exit codes: 0 = clean, 1 = leak detected, 2 = fail-closed
7
+ error (config/adapter), matching the handoff's contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import secrets
15
+ from collections.abc import Sequence
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+
19
+ from trimgate.adapter import resolve_adapter
20
+ from trimgate.corpus import load_corpus
21
+ from trimgate.diff import trim_gap
22
+ from trimgate.engine import Outcome
23
+ from trimgate.matrix import load_matrix
24
+ from trimgate.probe import load_probeset
25
+ from trimgate.receipt import build_receipt, canonical_json, seal_receipt, to_markdown
26
+ from trimgate.runner import RunResult, run
27
+ from trimgate.signing import (
28
+ generate_private_key,
29
+ key_fingerprint,
30
+ load_private_key,
31
+ load_public_key,
32
+ private_key_to_pem,
33
+ public_key_to_pem,
34
+ sign,
35
+ verify,
36
+ )
37
+
38
+ _MARK = {
39
+ Outcome.PASS: "[ ok ]",
40
+ Outcome.LEAK: "[FAIL]",
41
+ Outcome.ERROR: "[ERR ]",
42
+ Outcome.SKIPPED: "[SKIP]",
43
+ }
44
+
45
+
46
+ def _print_summary(result: RunResult) -> None:
47
+ for verdict in result.verdicts:
48
+ line = (
49
+ f"{_MARK[verdict.status]} {verdict.probe_id} "
50
+ f"{verdict.identity} {verdict.probe_class.value}"
51
+ )
52
+ if verdict.status is Outcome.LEAK:
53
+ line += " LEAK: " + ", ".join(sorted(verdict.leaked_ids))
54
+ elif verdict.detail:
55
+ line += f" {verdict.detail}"
56
+ print(line)
57
+
58
+ identities = len({v.identity for v in result.verdicts})
59
+ verdict_word = "PASS" if result.passed else "FAIL"
60
+ print(
61
+ f"TrimGate: {identities} identities, {len(result.verdicts)} probes, "
62
+ f"{result.total_leaks} leak(s), {result.total_errors} error(s), "
63
+ f"{result.total_skipped} skipped == VERDICT: {verdict_word}"
64
+ )
65
+ if result.total_skipped:
66
+ print(
67
+ f"NOTE: {result.total_skipped} probe(s) SKIPPED (adapter lacks the method) "
68
+ f"-- those access paths were NOT audited"
69
+ )
70
+
71
+
72
+ def _execute(args: argparse.Namespace) -> RunResult:
73
+ matrix = load_matrix(args.matrix)
74
+ corpus = load_corpus(args.corpus)
75
+ probeset = load_probeset(args.probes)
76
+ adapter = resolve_adapter(args.adapter)
77
+ return run(probeset, adapter, matrix, corpus)
78
+
79
+
80
+ def _exit_code(result: RunResult) -> int:
81
+ if result.passed:
82
+ return 0
83
+ # 1 = leak; 2 = fail-closed error or a vacuous (zero-probe) run.
84
+ return 1 if result.total_leaks else 2
85
+
86
+
87
+ def _cmd_run(args: argparse.Namespace) -> int:
88
+ result = _execute(args)
89
+ _print_summary(result)
90
+
91
+ if args.out or args.report_md or args.sign:
92
+ receipt = build_receipt(
93
+ result, matrix_path=args.matrix, corpus_path=args.corpus, adapter=args.adapter
94
+ )
95
+ if args.report_md:
96
+ Path(args.report_md).write_text(to_markdown(receipt), encoding="utf-8", newline="\n")
97
+ if args.sign:
98
+ if not args.out:
99
+ raise SystemExit("--sign requires --out")
100
+ if not args.key:
101
+ raise SystemExit("--sign requires --key (an Ed25519 private key PEM)")
102
+ private_key = load_private_key(Path(args.key).read_bytes())
103
+ sealed = seal_receipt(
104
+ receipt,
105
+ signed_at=datetime.now(UTC).isoformat(),
106
+ nonce=secrets.token_hex(16),
107
+ key_fingerprint=key_fingerprint(private_key.public_key()),
108
+ )
109
+ canonical = canonical_json(sealed)
110
+ Path(args.out).write_text(canonical + "\n", encoding="utf-8", newline="\n")
111
+ signature = sign(canonical.encode("utf-8"), private_key)
112
+ Path(args.out + ".sig").write_text(signature + "\n", encoding="utf-8", newline="\n")
113
+ elif args.out:
114
+ Path(args.out).write_text(
115
+ canonical_json(receipt) + "\n", encoding="utf-8", newline="\n"
116
+ )
117
+
118
+ return _exit_code(result)
119
+
120
+
121
+ def _cmd_keygen(args: argparse.Namespace) -> int:
122
+ private_key = generate_private_key()
123
+ Path(args.private).write_bytes(private_key_to_pem(private_key))
124
+ Path(args.public).write_bytes(public_key_to_pem(private_key.public_key()))
125
+ print(f"wrote private key {args.private} and public key {args.public}")
126
+ return 0
127
+
128
+
129
+ def _cmd_verify(args: argparse.Namespace) -> int:
130
+ receipt = json.loads(Path(args.receipt).read_text(encoding="utf-8"))
131
+ canonical = canonical_json(receipt).encode("utf-8")
132
+ sig_path = args.sig or (args.receipt + ".sig")
133
+ signature = Path(sig_path).read_text(encoding="utf-8").strip()
134
+ public_key = load_public_key(Path(args.public).read_bytes())
135
+
136
+ if not verify(canonical, signature, public_key):
137
+ print(f"INVALID: {args.receipt} signature does not verify (tampered or wrong key)")
138
+ return 1
139
+
140
+ # The signed payload binds the signer's key fingerprint; confirm the supplied
141
+ # key actually signed it, and (if pinned) that it is the trusted signer.
142
+ provenance = receipt.get("provenance") or {}
143
+ declared = provenance.get("key_fingerprint")
144
+ actual = key_fingerprint(public_key)
145
+ if declared != actual:
146
+ print(f"INVALID: {args.receipt} was not signed by the provided key (unsigned receipt?)")
147
+ return 1
148
+ if args.expect_fingerprint and args.expect_fingerprint != actual:
149
+ print("INVALID: signer fingerprint does not match the pinned --expect-fingerprint")
150
+ return 1
151
+
152
+ # A valid signature over a FAIL receipt is still a fail: do not report it OK.
153
+ verdict = receipt.get("verdict")
154
+ if verdict != "pass":
155
+ print(f"INVALID: signature valid, but the audit verdict is {verdict!r} (not pass)")
156
+ return 1
157
+
158
+ trust = "trusted (pinned)" if args.expect_fingerprint else "UNTRUSTED -- signer not pinned"
159
+ print(
160
+ f"OK: verdict=pass, signer {actual[:16]} [{trust}], "
161
+ f"self-reported signed_at {provenance.get('signed_at')}"
162
+ )
163
+ return 0
164
+
165
+
166
+ def _cmd_diff(args: argparse.Namespace) -> int:
167
+ result = _execute(args)
168
+ gap = trim_gap(result)
169
+ for identity in sorted(gap):
170
+ print(f"{identity} improperly reachable: {', '.join(sorted(gap[identity]))}")
171
+
172
+ if result.total_errors or not result.verdicts:
173
+ # An errored or vacuous audit is NOT clean; never report "none" for it.
174
+ print(
175
+ f"trim gap: INCOMPLETE -- {result.total_errors} error(s) over "
176
+ f"{len(result.verdicts)} probe(s); audit did not run cleanly"
177
+ )
178
+ elif not gap:
179
+ print("trim gap: none -- every identity stayed inside its authorized set")
180
+ return _exit_code(result)
181
+
182
+
183
+ def main(argv: Sequence[str] | None = None) -> int:
184
+ parser = argparse.ArgumentParser(prog="trimgate")
185
+ sub = parser.add_subparsers(dest="command", required=True)
186
+
187
+ run_parser = sub.add_parser("run", help="run a probeset against an adapter")
188
+ _add_common_args(run_parser)
189
+ run_parser.add_argument("--out", help="write the canonical JSON receipt to this path")
190
+ run_parser.add_argument("--report-md", help="write a human-readable Markdown report")
191
+ run_parser.add_argument("--sign", action="store_true", help="Ed25519-sign the receipt")
192
+ run_parser.add_argument("--key", help="Ed25519 private key PEM (required with --sign)")
193
+ run_parser.set_defaults(func=_cmd_run)
194
+
195
+ diff_parser = sub.add_parser("diff", help="emit the per-identity trim gap")
196
+ _add_common_args(diff_parser)
197
+ diff_parser.set_defaults(func=_cmd_diff)
198
+
199
+ keygen_parser = sub.add_parser("keygen", help="generate an Ed25519 signing keypair")
200
+ keygen_parser.add_argument(
201
+ "--private", required=True, help="output path for the private key PEM"
202
+ )
203
+ keygen_parser.add_argument("--public", required=True, help="output path for the public key PEM")
204
+ keygen_parser.set_defaults(func=_cmd_keygen)
205
+
206
+ verify_parser = sub.add_parser("verify", help="verify a signed receipt offline")
207
+ verify_parser.add_argument("receipt", help="path to the receipt JSON")
208
+ verify_parser.add_argument("--sig", help="path to the signature (default: <receipt>.sig)")
209
+ verify_parser.add_argument("--public", required=True, help="path to the public key PEM")
210
+ verify_parser.add_argument(
211
+ "--expect-fingerprint", help="pin the trusted signer's key fingerprint"
212
+ )
213
+ verify_parser.set_defaults(func=_cmd_verify)
214
+
215
+ args = parser.parse_args(argv)
216
+ return int(args.func(args))
217
+
218
+
219
+ def _add_common_args(parser: argparse.ArgumentParser) -> None:
220
+ parser.add_argument("--adapter", required=True, help="MODULE:ATTR adapter import path")
221
+ parser.add_argument("--matrix", required=True, help="path to access-matrix YAML")
222
+ parser.add_argument("--corpus", required=True, help="path to labeled-corpus JSONL")
223
+ parser.add_argument("--probes", required=True, help="path to probes YAML")
trimgate/corpus.py ADDED
@@ -0,0 +1,76 @@
1
+ """Labeled corpus: chunks under test, each carrying the label that decides who
2
+ may see it. Authorization is resolved against the access-matrix (section 0):
3
+ authorized(identity) = chunk-IDs whose label is in the identity's allowed labels.
4
+
5
+ The corpus is validated at construction: a chunk-ID must map to exactly one label.
6
+ A duplicated chunk-ID is rejected, because authorization unions over chunks, so a
7
+ public copy of a secret chunk-ID would otherwise silently authorize the secret.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from trimgate.matrix import AccessMatrix
18
+
19
+
20
+ class CorpusError(ValueError):
21
+ """The corpus is malformed or internally inconsistent (fail-closed)."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Chunk:
26
+ chunk_id: str
27
+ label: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Corpus:
32
+ chunks: tuple[Chunk, ...]
33
+
34
+ def __post_init__(self) -> None:
35
+ seen: dict[str, str] = {}
36
+ for chunk in self.chunks:
37
+ if chunk.chunk_id in seen:
38
+ raise CorpusError(
39
+ f"duplicate chunk_id {chunk.chunk_id!r} "
40
+ f"(labels {seen[chunk.chunk_id]!r} and {chunk.label!r})"
41
+ )
42
+ seen[chunk.chunk_id] = chunk.label
43
+
44
+ def authorized_for(self, matrix: AccessMatrix, identity: str) -> frozenset[str]:
45
+ allowed = matrix.allowed_labels(identity)
46
+ return frozenset(c.chunk_id for c in self.chunks if c.label in allowed)
47
+
48
+ def chunk_ids(self) -> frozenset[str]:
49
+ return frozenset(c.chunk_id for c in self.chunks)
50
+
51
+
52
+ def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
53
+ seen: dict[str, object] = {}
54
+ for key, value in pairs:
55
+ if key in seen:
56
+ raise CorpusError(f"duplicate JSON key {key!r} (last-key-wins could relabel a chunk)")
57
+ seen[key] = value
58
+ return seen
59
+
60
+
61
+ def load_corpus(path: str) -> Corpus:
62
+ chunks: list[Chunk] = []
63
+ with open(path, encoding="utf-8") as fh:
64
+ for lineno, raw in enumerate(fh, start=1):
65
+ line = raw.strip()
66
+ if not line:
67
+ continue
68
+ try:
69
+ obj = json.loads(line, object_pairs_hook=_reject_duplicate_keys)
70
+ except json.JSONDecodeError as exc:
71
+ raise CorpusError(f"{path}:{lineno}: invalid JSON: {exc}") from exc
72
+ chunk_id, label = obj.get("chunk_id"), obj.get("label")
73
+ if not isinstance(chunk_id, str) or not isinstance(label, str):
74
+ raise CorpusError(f"{path}:{lineno}: chunk needs string 'chunk_id' and 'label'")
75
+ chunks.append(Chunk(chunk_id=chunk_id, label=label))
76
+ return Corpus(chunks=tuple(chunks))
trimgate/demo.py ADDED
@@ -0,0 +1,46 @@
1
+ """Demo adapter factories for the quickstart seed (examples/quickstart).
2
+
3
+ These model a host RAG system under test. `clean_adapter` trims correctly for
4
+ every quickstart probe; `leaky_adapter` reproduces a real ACL-drift failure where
5
+ bob's retrieval and direct-fetch surface the finance salary chunk he may not see.
6
+ Both are constructible with no arguments so the CLI can reference them as
7
+ `trimgate.demo:clean_adapter` / `trimgate.demo:leaky_adapter`.
8
+
9
+ The adapter is the system under test; it is authored independently of the
10
+ access-matrix. TrimGate's job is to catch when the two disagree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from trimgate.fake import FakeAdapter
16
+
17
+ _BOB_SALARY_QUERY = "what are the engineering salary bands?"
18
+ _CAROL_SALARY_QUERY = "what are the engineering salary bands?"
19
+
20
+
21
+ def clean_adapter() -> FakeAdapter:
22
+ """Every probe stays inside the identity's authorized set: a PASS run."""
23
+ return FakeAdapter(
24
+ retrievals={
25
+ ("alice", "what is the parental leave policy?"): ["c-parental"],
26
+ ("bob", _BOB_SALARY_QUERY): ["c-readme"], # correctly trimmed
27
+ ("carol", _CAROL_SALARY_QUERY): ["c-salary"], # carol may see fin-secret
28
+ },
29
+ fetches={
30
+ ("bob", "doc-salary-bands"): [], # correctly denied
31
+ },
32
+ )
33
+
34
+
35
+ def leaky_adapter() -> FakeAdapter:
36
+ """ACL drift: bob's retrieval and direct-fetch leak the finance chunk."""
37
+ return FakeAdapter(
38
+ retrievals={
39
+ ("alice", "what is the parental leave policy?"): ["c-parental"],
40
+ ("bob", _BOB_SALARY_QUERY): ["c-readme", "c-salary"], # LEAK
41
+ ("carol", _CAROL_SALARY_QUERY): ["c-salary"],
42
+ },
43
+ fetches={
44
+ ("bob", "doc-salary-bands"): ["c-salary"], # LEAK
45
+ },
46
+ )
trimgate/diff.py ADDED
@@ -0,0 +1,19 @@
1
+ """Access-diff: the trim gap.
2
+
3
+ The trim gap is the headline artifact: per identity, the set of chunk-IDs the
4
+ pipeline made improperly reachable (surfaced but not authorized), aggregated
5
+ across every probe in a run. An empty gap for every identity is a clean audit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from trimgate.engine import Outcome
11
+ from trimgate.runner import RunResult
12
+
13
+
14
+ def trim_gap(result: RunResult) -> dict[str, frozenset[str]]:
15
+ gap: dict[str, set[str]] = {}
16
+ for verdict in result.verdicts:
17
+ if verdict.status is Outcome.LEAK:
18
+ gap.setdefault(verdict.identity, set()).update(verdict.leaked_ids)
19
+ return {identity: frozenset(ids) for identity, ids in gap.items()}
trimgate/engine.py ADDED
@@ -0,0 +1,114 @@
1
+ """The assertion engine: the heart of the trust path.
2
+
3
+ For each probe it compares the chunk-IDs the adapter surfaced against the set the
4
+ identity is authorized to see (derived from the access-matrix), by pure set
5
+ arithmetic. No model, no fuzzy match, no randomness. Binary verdict: a probe is a
6
+ LEAK iff it surfaced any chunk outside the authorized set, else PASS.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+ from typing import Any, cast
14
+
15
+ from trimgate.adapter import RagAdapter
16
+ from trimgate.corpus import Corpus
17
+ from trimgate.matrix import AccessMatrix, UnknownIdentityError
18
+ from trimgate.probe import Probe, ProbeClass
19
+
20
+ # Probe class -> the optional adapter method it requires (retrieval needs the
21
+ # required `retrieve`, so it is absent here and never skips).
22
+ _REQUIRED_OPTIONAL_METHOD = {
23
+ ProbeClass.DIRECT_FETCH: "fetch_resource",
24
+ ProbeClass.ENUMERATION: "list_resources",
25
+ }
26
+
27
+
28
+ class Outcome(StrEnum):
29
+ PASS = "pass"
30
+ LEAK = "leak"
31
+ ERROR = "error"
32
+ SKIPPED = "skipped"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Verdict:
37
+ probe_id: str
38
+ identity: str
39
+ probe_class: ProbeClass
40
+ status: Outcome
41
+ surfaced_ids: frozenset[str]
42
+ leaked_ids: frozenset[str]
43
+ detail: str = ""
44
+
45
+
46
+ def _surface(probe: Probe, adapter: RagAdapter) -> list[str]:
47
+ """Call the adapter method the probe's class selects, returning chunk-IDs.
48
+
49
+ The optional methods are reached via getattr so the base protocol need only
50
+ declare `retrieve`; presence is checked by the caller before dispatch.
51
+ """
52
+ if probe.probe_class is ProbeClass.RETRIEVAL:
53
+ if probe.query is None:
54
+ raise ValueError(f"retrieval probe {probe.id} has no query")
55
+ return list(adapter.retrieve(probe.query, probe.identity))
56
+ if probe.probe_class is ProbeClass.DIRECT_FETCH:
57
+ if probe.resource_ref is None:
58
+ raise ValueError(f"direct_fetch probe {probe.id} has no resource_ref")
59
+ return list(cast("Any", adapter).fetch_resource(probe.resource_ref, probe.identity))
60
+ if probe.probe_class is ProbeClass.ENUMERATION:
61
+ return list(cast("Any", adapter).list_resources(probe.identity))
62
+ raise ValueError(f"unknown probe_class: {probe.probe_class!r}")
63
+
64
+
65
+ def _verdict(
66
+ probe: Probe,
67
+ status: Outcome,
68
+ surfaced: frozenset[str],
69
+ leaked: frozenset[str],
70
+ detail: str = "",
71
+ ) -> Verdict:
72
+ return Verdict(probe.id, probe.identity, probe.probe_class, status, surfaced, leaked, detail)
73
+
74
+
75
+ def evaluate(probe: Probe, adapter: RagAdapter, matrix: AccessMatrix, corpus: Corpus) -> Verdict:
76
+ empty: frozenset[str] = frozenset()
77
+
78
+ # A probe whose class needs an unimplemented optional method is skipped and
79
+ # logged (never silently passed).
80
+ needed = _REQUIRED_OPTIONAL_METHOD.get(probe.probe_class)
81
+ if needed is not None and not hasattr(adapter, needed):
82
+ return _verdict(
83
+ probe,
84
+ Outcome.SKIPPED,
85
+ empty,
86
+ empty,
87
+ detail=f"adapter has no optional method {needed!r}",
88
+ )
89
+
90
+ # The adapter is the only impure boundary; any failure is fail-closed.
91
+ try:
92
+ surfaced = frozenset(_surface(probe, adapter))
93
+ except Exception as exc: # noqa: BLE001 - fail-closed on any backend failure
94
+ return _verdict(probe, Outcome.ERROR, empty, empty, detail=f"adapter error: {exc}")
95
+
96
+ # An identity not declared in the matrix is a config error, never a pass.
97
+ try:
98
+ authorized = corpus.authorized_for(matrix, probe.identity)
99
+ except UnknownIdentityError:
100
+ return _verdict(
101
+ probe, Outcome.ERROR, surfaced, empty, detail=f"unknown identity {probe.identity!r}"
102
+ )
103
+
104
+ # A surfaced chunk-ID that the corpus cannot classify is fail-closed, never
105
+ # treated as authorized.
106
+ unknown = surfaced - corpus.chunk_ids()
107
+ if unknown:
108
+ return _verdict(
109
+ probe, Outcome.ERROR, surfaced, empty, detail=f"unknown chunk(s): {sorted(unknown)}"
110
+ )
111
+
112
+ leaked = surfaced - authorized
113
+ status = Outcome.LEAK if leaked else Outcome.PASS
114
+ return _verdict(probe, status, surfaced, leaked)