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 +10 -0
- trimgate/adapter.py +63 -0
- trimgate/adapters/__init__.py +1 -0
- trimgate/adapters/azure.py +113 -0
- trimgate/cli.py +223 -0
- trimgate/corpus.py +76 -0
- trimgate/demo.py +46 -0
- trimgate/diff.py +19 -0
- trimgate/engine.py +114 -0
- trimgate/fake.py +32 -0
- trimgate/matrix.py +67 -0
- trimgate/plugin.py +118 -0
- trimgate/probe.py +72 -0
- trimgate/py.typed +0 -0
- trimgate/receipt.py +129 -0
- trimgate/runner.py +55 -0
- trimgate/signing.py +73 -0
- trimgate-0.0.1.dist-info/METADATA +183 -0
- trimgate-0.0.1.dist-info/RECORD +24 -0
- trimgate-0.0.1.dist-info/WHEEL +4 -0
- trimgate-0.0.1.dist-info/entry_points.txt +5 -0
- trimgate-0.0.1.dist-info/licenses/LICENSE +201 -0
- trimgate-0.0.1.dist-info/licenses/LICENSES/CC-BY-4.0.txt +29 -0
- trimgate-0.0.1.dist-info/licenses/NOTICE +13 -0
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)
|