aggrete 0.1.0__tar.gz
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.
- aggrete-0.1.0/PKG-INFO +315 -0
- aggrete-0.1.0/README.md +284 -0
- aggrete-0.1.0/aggrete/__init__.py +0 -0
- aggrete-0.1.0/aggrete/accumulator.py +111 -0
- aggrete-0.1.0/aggrete/auth.py +96 -0
- aggrete-0.1.0/aggrete/connectors/__init__.py +0 -0
- aggrete-0.1.0/aggrete/connectors/drive.py +170 -0
- aggrete-0.1.0/aggrete/entities.py +69 -0
- aggrete-0.1.0/aggrete/ingest.py +260 -0
- aggrete-0.1.0/aggrete/plugin.py +158 -0
- aggrete-0.1.0/aggrete/policy.py +219 -0
- aggrete-0.1.0/aggrete/proxy.py +340 -0
- aggrete-0.1.0/aggrete/signin.py +157 -0
- aggrete-0.1.0/aggrete.egg-info/PKG-INFO +315 -0
- aggrete-0.1.0/aggrete.egg-info/SOURCES.txt +27 -0
- aggrete-0.1.0/aggrete.egg-info/dependency_links.txt +1 -0
- aggrete-0.1.0/aggrete.egg-info/entry_points.txt +3 -0
- aggrete-0.1.0/aggrete.egg-info/requires.txt +17 -0
- aggrete-0.1.0/aggrete.egg-info/top_level.txt +1 -0
- aggrete-0.1.0/pyproject.toml +44 -0
- aggrete-0.1.0/setup.cfg +4 -0
- aggrete-0.1.0/tests/test_auth.py +51 -0
- aggrete-0.1.0/tests/test_coc.py +98 -0
- aggrete-0.1.0/tests/test_drive_connector.py +36 -0
- aggrete-0.1.0/tests/test_http_transport.py +112 -0
- aggrete-0.1.0/tests/test_http_upstream.py +98 -0
- aggrete-0.1.0/tests/test_ingest.py +23 -0
- aggrete-0.1.0/tests/test_plugin.py +90 -0
- aggrete-0.1.0/tests/test_signin.py +77 -0
aggrete-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aggrete
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An MCP proxy that enforces your code of conduct across connectors. Permission is not need-to-know.
|
|
5
|
+
Author: Christian Johannsen
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://aggrete.com
|
|
8
|
+
Project-URL: Repository, https://github.com/cjohannsen81/aggrete
|
|
9
|
+
Keywords: mcp,model-context-protocol,policy,proxy,gateway,security,aggregation
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Information Technology
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: mcp>=2.0
|
|
18
|
+
Requires-Dist: pyyaml>=6
|
|
19
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
20
|
+
Requires-Dist: starlette>=0.40
|
|
21
|
+
Requires-Dist: uvicorn>=0.30
|
|
22
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
23
|
+
Provides-Extra: redis
|
|
24
|
+
Requires-Dist: redis>=5; extra == "redis"
|
|
25
|
+
Provides-Extra: ingest
|
|
26
|
+
Requires-Dist: anthropic>=1.0; extra == "ingest"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest; extra == "dev"
|
|
29
|
+
Requires-Dist: redis>=5; extra == "dev"
|
|
30
|
+
Requires-Dist: anthropic>=1.0; extra == "dev"
|
|
31
|
+
|
|
32
|
+
# Aggrete
|
|
33
|
+
|
|
34
|
+
The open-source proxy. Product site: https://aggrete.com. This repo is the proxy and nothing else: engine, accumulator, ingest CLI, Helm chart.
|
|
35
|
+
|
|
36
|
+
An MCP proxy that enforces a **code of conduct document** across connectors, with
|
|
37
|
+
state that accumulates per user.
|
|
38
|
+
|
|
39
|
+
Every MCP gateway on the market authorizes tool calls and logs them. None of them
|
|
40
|
+
answer the question that actually matters once an assistant can reach Glean,
|
|
41
|
+
Salesforce, Slack and Drive at once: *is this call, combined with everything this
|
|
42
|
+
person has already pulled today, something the code of conduct forbids?*
|
|
43
|
+
|
|
44
|
+
Four individually-authorized questions can assemble a layoff list. No guardrail
|
|
45
|
+
fires, because no single question was sensitive. This proxy is the missing layer.
|
|
46
|
+
|
|
47
|
+
## Run it
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m venv .venv && .venv/bin/pip install mcp pyyaml pytest
|
|
51
|
+
.venv/bin/python -m pytest tests -q # tests generated from coc.yaml
|
|
52
|
+
.venv/bin/python demo/run_demo.py # the four-prompt sequence, end to end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Output:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
turn 1 finance__headcount_plan allowed
|
|
59
|
+
turn 2 finance__budget_roles allowed
|
|
60
|
+
turn 3 hr__recent_joiners allowed (alert: COC-HR-011, 20 distinct > 8)
|
|
61
|
+
turn 4 ops__oncall_draft DENIED COC-HR-004
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Turn 4 is denied **before the upstream call**, so the on-call data is never
|
|
65
|
+
fetched. The two domains already held overlap on the same people, and this call
|
|
66
|
+
would complete the forbidden set.
|
|
67
|
+
|
|
68
|
+
## The document is the source of truth
|
|
69
|
+
|
|
70
|
+
`coc.yaml` holds clause text written by the clause owner, its enforcement, and its
|
|
71
|
+
tests. Engineering owns the compiler, not the policy.
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
- rule_id: COC-HR-004
|
|
75
|
+
clause: >
|
|
76
|
+
Personnel records, compensation or budget records, and operational rosters
|
|
77
|
+
may not be combined to derive the employment status, performance, or
|
|
78
|
+
planned departure of identifiable individuals.
|
|
79
|
+
owner: hr-privacy@example.com
|
|
80
|
+
enforce:
|
|
81
|
+
- layer: accumulation
|
|
82
|
+
action: deny
|
|
83
|
+
type: domain_join
|
|
84
|
+
domains: [hr-personnel, finance-comp, ops-rota]
|
|
85
|
+
require_entity_overlap: true
|
|
86
|
+
scope: user
|
|
87
|
+
window: 4h
|
|
88
|
+
tests:
|
|
89
|
+
- {name: four_prompt_layoff_list, expect: deny, sequence: [...]}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
CI fails any rule without both an allow and a deny test. Clauses that compile to
|
|
93
|
+
nothing are worth finding. Those are the parts of your code of conduct that were
|
|
94
|
+
never enforceable.
|
|
95
|
+
|
|
96
|
+
Rule types: `domain_join`, `entity_budget`, `domain_block`, `self_comparison`,
|
|
97
|
+
`min_group` (a result about fewer than k people is one person's data; pay
|
|
98
|
+
transparency), `wall` (a domain open only to `allowed_users`, or closed to
|
|
99
|
+
`blocked_users`, optionally `until` a date; privilege, embargoes, investigation
|
|
100
|
+
subjects). `domain_join` and `domain_block` accept the same `allowed_users`,
|
|
101
|
+
`blocked_users`, `since`, `until` scoping (quiet periods). `self_comparison`
|
|
102
|
+
(the requester's own record plus colleagues' records in one domain. The
|
|
103
|
+
precondition for "how do I compare"; decided post-call, since the colleague
|
|
104
|
+
records have to be seen to be counted). Actions: `deny`, `alert`. Start everything at `alert`, tune against real traffic, then flip.
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
client ──MCP──▶ proxy ──MCP──▶ hr / finance / ops connectors
|
|
110
|
+
│
|
|
111
|
+
├─ pre_call deny before fetching where already decidable
|
|
112
|
+
├─ post_call extract entities, record, re-evaluate, redact
|
|
113
|
+
└─ audit what was handed over, not just what was asked
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- `aggrete/policy.py`. Deterministic evaluation. No model in this path.
|
|
117
|
+
- `aggrete/accumulator.py`. Per-user state, TTL'd. `MemoryStore` for tests,
|
|
118
|
+
`RedisStore` for deployment, because state must be shared across clients.
|
|
119
|
+
- `aggrete/entities.py`. Pulls stable person IDs out of tool results.
|
|
120
|
+
- `proxy.config.yaml`. Maps tool name patterns to the domains clauses refer to.
|
|
121
|
+
|
|
122
|
+
### Remote connectors
|
|
123
|
+
|
|
124
|
+
Upstreams are either local stdio processes (`command:`) or remote MCP
|
|
125
|
+
servers over streamable HTTP (`url:`). The proxy holds the credential for the
|
|
126
|
+
upstream; header values may reference `${ENV_VARS}` so tokens never sit in
|
|
127
|
+
the YAML. Because the end user never holds that token, the only path to the
|
|
128
|
+
connector is through the proxy.
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
upstreams:
|
|
132
|
+
ops:
|
|
133
|
+
url: https://mcp.example.com/ops/mcp
|
|
134
|
+
headers:
|
|
135
|
+
Authorization: "Bearer ${OPS_MCP_TOKEN}"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`tests/test_http_upstream.py` runs the mock `ops` connector over HTTP
|
|
139
|
+
(`demo/mock_server.py --transport streamable-http`) behind the proxy end to end.
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
## Architecture: where the proxy lives and how the pieces connect
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
people's assistants your network your systems
|
|
146
|
+
(Claude, Copilot, Cursor) | |
|
|
147
|
+
| mcp.example.com (this proxy) | HR system (Workday)
|
|
148
|
+
── HTTPS + OAuth ────────► | Starlette, streamable HTTP | ─► Finance (budget lines)
|
|
149
|
+
| identity from the token | ─► On-call rotations
|
|
150
|
+
| policy: coc.yaml | ─► Drive, Slack, CRM ...
|
|
151
|
+
| state: Redis (or memory) | (reachable only from the proxy)
|
|
152
|
+
| │ writes |
|
|
153
|
+
| ▼ |
|
|
154
|
+
| audit.jsonl ◄── read only ── Aggrete Console (live.example.com)
|
|
155
|
+
| coc.yaml HR / Legal / IT, behind SSO or basic auth
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Three rules make this safe:
|
|
159
|
+
|
|
160
|
+
1. **Only the proxy holds connector credentials.** People sign in to the proxy
|
|
161
|
+
(your IdP via `mode: jwt`, or the built-in sign-in via `mode: builtin` when
|
|
162
|
+
you have no IdP yet); the proxy signs in to the connectors. Fence the
|
|
163
|
+
connectors so they accept traffic only from the proxy host.
|
|
164
|
+
2. **The console never touches the connectors.** It reads two files the proxy
|
|
165
|
+
writes, `audit.jsonl` and `coc.yaml`, on the same host or a shared volume,
|
|
166
|
+
and it changes nothing the proxy enforces. Put it behind your SSO or, at
|
|
167
|
+
minimum, HTTP basic auth; it shows who asked what.
|
|
168
|
+
3. **The assistants may only talk to the proxy.** Managed client policy
|
|
169
|
+
(Claude Code managed settings, Claude Enterprise connectors, Copilot and
|
|
170
|
+
Cursor org policies) allow-lists `https://mcp.example.com/mcp` and nothing
|
|
171
|
+
else.
|
|
172
|
+
|
|
173
|
+
Connecting Claude (claude.ai): Settings → Connectors → Add custom connector →
|
|
174
|
+
URL `https://mcp.example.com/mcp`. Claude discovers the sign-in from the
|
|
175
|
+
proxy's OAuth metadata, registers itself, and sends you to `/signin`. From then
|
|
176
|
+
on every question Claude asks on your behalf passes the policy.
|
|
177
|
+
|
|
178
|
+
Sample handbook: `samples/northwind-handbook.docx`; `coc.yaml` maps to its
|
|
179
|
+
clauses 7.1 to 7.11 one to one (7.4 and 7.12 are not enforceable at a data
|
|
180
|
+
proxy). `aggrete-ingest samples/northwind-handbook.docx` reproduces it.
|
|
181
|
+
|
|
182
|
+
## Serving it to a whole company: streamable HTTP + OAuth
|
|
183
|
+
|
|
184
|
+
stdio is for one laptop. For everyone else, run Aggrete as a service and let
|
|
185
|
+
identity come from the token:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python -m aggrete.proxy --config proxy.config.yaml --transport streamable-http --host 0.0.0.0 --port 8080
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
HTTP mode refuses to start without an `auth:` block. In `jwt` mode it validates
|
|
192
|
+
bearer JWTs from your IdP (issuer, audience, expiry, signature via JWKS,
|
|
193
|
+
required scopes) and derives the user from the `email` claim. Configurable
|
|
194
|
+
with `identity_claim`. Every request without a valid token is a 401 with an
|
|
195
|
+
RFC 9728 `WWW-Authenticate` pointer, and the `user:` line in the config is
|
|
196
|
+
ignored entirely. `builtin` mode is a small OAuth server inside the proxy (dynamic client
|
|
197
|
+
registration, a sign-in page, passcodes from the environment) for teams with
|
|
198
|
+
no IdP yet. `static` mode (fixed tokens) exists for development and the
|
|
199
|
+
test-suite. The accumulator keys state on the token identity, so the same
|
|
200
|
+
person hitting Aggrete from Claude Code, Claude.ai and Cursor shares one
|
|
201
|
+
history. Which is the point.
|
|
202
|
+
|
|
203
|
+
Register it in a client as a remote MCP server at `https://<host>/mcp` with
|
|
204
|
+
the bearer token your IdP issues; keep the connectors themselves reachable
|
|
205
|
+
only from the Aggrete host.
|
|
206
|
+
|
|
207
|
+
## Inside a gateway you already run
|
|
208
|
+
|
|
209
|
+
If agentgateway, IBM ContextForge, Kong or your own gateway is already the
|
|
210
|
+
control plane, don't add a second one. Embed Aggrete:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from aggrete.plugin import PolicyHook, AggreteMiddleware
|
|
214
|
+
|
|
215
|
+
hook = PolicyHook("coc.yaml", domains={"hr__*": "hr-personnel", "ops__*": "ops-rota"},
|
|
216
|
+
store=RedisStore(redis_client))
|
|
217
|
+
# as two calls from your plugin system
|
|
218
|
+
v = hook.before(user, tool) # v.allow, v.message (clause + remediation)
|
|
219
|
+
v = hook.after(user, tool, result_text) # records entities, re-evaluates
|
|
220
|
+
# or as ASGI middleware around any MCP server that answers in JSON
|
|
221
|
+
app = AggreteMiddleware(app, hook, identity=lambda scope: scope["state"]["user"])
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Identity is a callable over the request, so it composes with whatever auth
|
|
225
|
+
the host performs. The middleware refuses at pre-call without forwarding and
|
|
226
|
+
inspects JSON tools/call results for post-call recording.
|
|
227
|
+
|
|
228
|
+
## Ways to deploy
|
|
229
|
+
|
|
230
|
+
| Who | How |
|
|
231
|
+
|---|---|
|
|
232
|
+
| One developer | `uvx aggrete --config proxy.config.yaml` (PyPI) or the `.mcp.json` in this repo |
|
|
233
|
+
| A team | `docker run ghcr.io/cjohannsen81/aggrete` with `/etc/aggrete` mounted, or `helm install aggrete deploy/helm/aggrete` (bundled Redis, JWT auth, Ingress) |
|
|
234
|
+
| A company | Helm/Docker behind your IdP, then make `https://aggrete.<corp>/mcp` the *only* MCP server your assistant policies allow (Claude Code managed settings, Claude Enterprise connectors, Copilot/Cursor org policies), with connectors network-restricted to the Aggrete hosts |
|
|
235
|
+
| Existing gateway | `aggrete.plugin` (above) |
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
## Putting a real system behind the proxy: Google Drive
|
|
239
|
+
|
|
240
|
+
`aggrete/connectors/drive.py` is a Drive upstream the proxy runs itself. How
|
|
241
|
+
it is done, in the order you do it:
|
|
242
|
+
|
|
243
|
+
1. **A service account, not a person.** In Google Cloud: enable the Drive API,
|
|
244
|
+
create a service account (say `aggrete-drive`), download its JSON key. The
|
|
245
|
+
proxy holds the key; nobody's personal Google login is involved, which is
|
|
246
|
+
what makes the proxy the only road.
|
|
247
|
+
2. **Share the folders, read only.** In Drive, create a root folder (say
|
|
248
|
+
`Northwind`) with one subfolder per kind of material (`Restructuring plan`,
|
|
249
|
+
`Legal hold`, `Team documents`) and share the root with the service account
|
|
250
|
+
email as **Viewer**. Service accounts own nothing; they only see what is
|
|
251
|
+
shared with them.
|
|
252
|
+
3. **One tool pair per folder.** The connector lists the root's subfolders and
|
|
253
|
+
exposes `search_<folder>` and `read_<folder>` for each, so the policy can
|
|
254
|
+
name folders:
|
|
255
|
+
```yaml
|
|
256
|
+
upstreams:
|
|
257
|
+
drive: {command: python3, args: [-m, aggrete.connectors.drive, --credentials, /opt/aggrete/drive-sa.json, --root, Northwind]}
|
|
258
|
+
domains:
|
|
259
|
+
"drive__*_restructuring_plan": restructuring-plan # clause 7.9: embargo until announced
|
|
260
|
+
"drive__*_legal_hold": legal-hold # clause 7.3: never for assistants
|
|
261
|
+
"drive__*": drive-general
|
|
262
|
+
```
|
|
263
|
+
4. **Results name people.** Every file comes back with `owner_email` and
|
|
264
|
+
`editor_email`, so the policy's tallies and joins work on Drive results
|
|
265
|
+
like on HR records.
|
|
266
|
+
5. **Remove the direct road.** Disable the assistant's native Drive connector
|
|
267
|
+
for governed accounts (Claude Enterprise: managed connectors; personal
|
|
268
|
+
accounts: remove it). Otherwise the assistant has two ways to Drive and the
|
|
269
|
+
policy only sees one.
|
|
270
|
+
|
|
271
|
+
`python -m aggrete.connectors.drive --credentials sa.json --root Northwind --list`
|
|
272
|
+
prints the tools that will be exposed. If the root is not shared yet the
|
|
273
|
+
connector still starts and exposes a single `status` tool that says what is
|
|
274
|
+
missing, so the proxy never fails to boot because of Drive.
|
|
275
|
+
|
|
276
|
+
## Starting from the document you already have
|
|
277
|
+
|
|
278
|
+
`aggrete/ingest.py` turns a code-of-conduct document into a draft `coc.yaml`:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
python -m aggrete.ingest handbook.pdf --domains proxy.config.yaml -o coc.draft.yaml
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
PDFs go to the model as native document blocks; DOCX, Markdown and text as
|
|
285
|
+
text. The model proposes rules in the exact `coc.yaml` schema with clause text
|
|
286
|
+
verbatim, every action forced to `alert`, and each rule's own tests are run
|
|
287
|
+
through the real `Engine` before the file is written. A draft that fails its
|
|
288
|
+
tests is rejected. Clauses no data proxy can enforce (tone, harassment,
|
|
289
|
+
expenses) are listed separately with the reason. Model set by `AGGRETE_INGEST_MODEL`. Needs `ANTHROPIC_API_KEY`
|
|
290
|
+
or an `ant auth login` profile.
|
|
291
|
+
|
|
292
|
+
## Purpose binding
|
|
293
|
+
|
|
294
|
+
A permanent block gets routed around. `engine.grant_purpose(user, rule_id,
|
|
295
|
+
purpose, ttl_s)` opens a scoped window and stamps every retrieval made under it
|
|
296
|
+
with the stated purpose. Wire it to an approval workflow owned by the clause
|
|
297
|
+
owner named in the rule.
|
|
298
|
+
|
|
299
|
+
## Honest limitations
|
|
300
|
+
|
|
301
|
+
- **Entity extraction is the weak point.** `entities.py` works on stable IDs and
|
|
302
|
+
emails. Tune `IDENTIFIER_KEYS` against your own connectors before trusting any
|
|
303
|
+
threshold, or Layer 4 will either never fire or fire constantly.
|
|
304
|
+
- **Post-call denial redacts, it does not un-fetch.** The data left the upstream.
|
|
305
|
+
Prefer rules that can be decided pre-call.
|
|
306
|
+
- **stdio identity is advisory.** The user is whoever launched the process and the
|
|
307
|
+
config is user-editable. Real enforcement needs streamable HTTP with OAuth, the
|
|
308
|
+
subject taken from the token, and IdP-level blocking of direct connector grants
|
|
309
|
+
so this proxy is the only path.
|
|
310
|
+
- **Aggregation cannot be solved, only narrowed.** A user who spaces requests
|
|
311
|
+
beyond the window, or paraphrases across systems this proxy doesn't front, gets
|
|
312
|
+
through. This raises the cost and creates the audit trail; it is not a ceiling.
|
|
313
|
+
- **Not a gateway.** No multi-tenancy, no token vault, no HA. For production,
|
|
314
|
+
port this policy engine onto agentgateway or IBM ContextForge as a plugin
|
|
315
|
+
rather than running it as your control plane.
|
aggrete-0.1.0/README.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# Aggrete
|
|
2
|
+
|
|
3
|
+
The open-source proxy. Product site: https://aggrete.com. This repo is the proxy and nothing else: engine, accumulator, ingest CLI, Helm chart.
|
|
4
|
+
|
|
5
|
+
An MCP proxy that enforces a **code of conduct document** across connectors, with
|
|
6
|
+
state that accumulates per user.
|
|
7
|
+
|
|
8
|
+
Every MCP gateway on the market authorizes tool calls and logs them. None of them
|
|
9
|
+
answer the question that actually matters once an assistant can reach Glean,
|
|
10
|
+
Salesforce, Slack and Drive at once: *is this call, combined with everything this
|
|
11
|
+
person has already pulled today, something the code of conduct forbids?*
|
|
12
|
+
|
|
13
|
+
Four individually-authorized questions can assemble a layoff list. No guardrail
|
|
14
|
+
fires, because no single question was sensitive. This proxy is the missing layer.
|
|
15
|
+
|
|
16
|
+
## Run it
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m venv .venv && .venv/bin/pip install mcp pyyaml pytest
|
|
20
|
+
.venv/bin/python -m pytest tests -q # tests generated from coc.yaml
|
|
21
|
+
.venv/bin/python demo/run_demo.py # the four-prompt sequence, end to end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Output:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
turn 1 finance__headcount_plan allowed
|
|
28
|
+
turn 2 finance__budget_roles allowed
|
|
29
|
+
turn 3 hr__recent_joiners allowed (alert: COC-HR-011, 20 distinct > 8)
|
|
30
|
+
turn 4 ops__oncall_draft DENIED COC-HR-004
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Turn 4 is denied **before the upstream call**, so the on-call data is never
|
|
34
|
+
fetched. The two domains already held overlap on the same people, and this call
|
|
35
|
+
would complete the forbidden set.
|
|
36
|
+
|
|
37
|
+
## The document is the source of truth
|
|
38
|
+
|
|
39
|
+
`coc.yaml` holds clause text written by the clause owner, its enforcement, and its
|
|
40
|
+
tests. Engineering owns the compiler, not the policy.
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
- rule_id: COC-HR-004
|
|
44
|
+
clause: >
|
|
45
|
+
Personnel records, compensation or budget records, and operational rosters
|
|
46
|
+
may not be combined to derive the employment status, performance, or
|
|
47
|
+
planned departure of identifiable individuals.
|
|
48
|
+
owner: hr-privacy@example.com
|
|
49
|
+
enforce:
|
|
50
|
+
- layer: accumulation
|
|
51
|
+
action: deny
|
|
52
|
+
type: domain_join
|
|
53
|
+
domains: [hr-personnel, finance-comp, ops-rota]
|
|
54
|
+
require_entity_overlap: true
|
|
55
|
+
scope: user
|
|
56
|
+
window: 4h
|
|
57
|
+
tests:
|
|
58
|
+
- {name: four_prompt_layoff_list, expect: deny, sequence: [...]}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
CI fails any rule without both an allow and a deny test. Clauses that compile to
|
|
62
|
+
nothing are worth finding. Those are the parts of your code of conduct that were
|
|
63
|
+
never enforceable.
|
|
64
|
+
|
|
65
|
+
Rule types: `domain_join`, `entity_budget`, `domain_block`, `self_comparison`,
|
|
66
|
+
`min_group` (a result about fewer than k people is one person's data; pay
|
|
67
|
+
transparency), `wall` (a domain open only to `allowed_users`, or closed to
|
|
68
|
+
`blocked_users`, optionally `until` a date; privilege, embargoes, investigation
|
|
69
|
+
subjects). `domain_join` and `domain_block` accept the same `allowed_users`,
|
|
70
|
+
`blocked_users`, `since`, `until` scoping (quiet periods). `self_comparison`
|
|
71
|
+
(the requester's own record plus colleagues' records in one domain. The
|
|
72
|
+
precondition for "how do I compare"; decided post-call, since the colleague
|
|
73
|
+
records have to be seen to be counted). Actions: `deny`, `alert`. Start everything at `alert`, tune against real traffic, then flip.
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
client ──MCP──▶ proxy ──MCP──▶ hr / finance / ops connectors
|
|
79
|
+
│
|
|
80
|
+
├─ pre_call deny before fetching where already decidable
|
|
81
|
+
├─ post_call extract entities, record, re-evaluate, redact
|
|
82
|
+
└─ audit what was handed over, not just what was asked
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- `aggrete/policy.py`. Deterministic evaluation. No model in this path.
|
|
86
|
+
- `aggrete/accumulator.py`. Per-user state, TTL'd. `MemoryStore` for tests,
|
|
87
|
+
`RedisStore` for deployment, because state must be shared across clients.
|
|
88
|
+
- `aggrete/entities.py`. Pulls stable person IDs out of tool results.
|
|
89
|
+
- `proxy.config.yaml`. Maps tool name patterns to the domains clauses refer to.
|
|
90
|
+
|
|
91
|
+
### Remote connectors
|
|
92
|
+
|
|
93
|
+
Upstreams are either local stdio processes (`command:`) or remote MCP
|
|
94
|
+
servers over streamable HTTP (`url:`). The proxy holds the credential for the
|
|
95
|
+
upstream; header values may reference `${ENV_VARS}` so tokens never sit in
|
|
96
|
+
the YAML. Because the end user never holds that token, the only path to the
|
|
97
|
+
connector is through the proxy.
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
upstreams:
|
|
101
|
+
ops:
|
|
102
|
+
url: https://mcp.example.com/ops/mcp
|
|
103
|
+
headers:
|
|
104
|
+
Authorization: "Bearer ${OPS_MCP_TOKEN}"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`tests/test_http_upstream.py` runs the mock `ops` connector over HTTP
|
|
108
|
+
(`demo/mock_server.py --transport streamable-http`) behind the proxy end to end.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
## Architecture: where the proxy lives and how the pieces connect
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
people's assistants your network your systems
|
|
115
|
+
(Claude, Copilot, Cursor) | |
|
|
116
|
+
| mcp.example.com (this proxy) | HR system (Workday)
|
|
117
|
+
── HTTPS + OAuth ────────► | Starlette, streamable HTTP | ─► Finance (budget lines)
|
|
118
|
+
| identity from the token | ─► On-call rotations
|
|
119
|
+
| policy: coc.yaml | ─► Drive, Slack, CRM ...
|
|
120
|
+
| state: Redis (or memory) | (reachable only from the proxy)
|
|
121
|
+
| │ writes |
|
|
122
|
+
| ▼ |
|
|
123
|
+
| audit.jsonl ◄── read only ── Aggrete Console (live.example.com)
|
|
124
|
+
| coc.yaml HR / Legal / IT, behind SSO or basic auth
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Three rules make this safe:
|
|
128
|
+
|
|
129
|
+
1. **Only the proxy holds connector credentials.** People sign in to the proxy
|
|
130
|
+
(your IdP via `mode: jwt`, or the built-in sign-in via `mode: builtin` when
|
|
131
|
+
you have no IdP yet); the proxy signs in to the connectors. Fence the
|
|
132
|
+
connectors so they accept traffic only from the proxy host.
|
|
133
|
+
2. **The console never touches the connectors.** It reads two files the proxy
|
|
134
|
+
writes, `audit.jsonl` and `coc.yaml`, on the same host or a shared volume,
|
|
135
|
+
and it changes nothing the proxy enforces. Put it behind your SSO or, at
|
|
136
|
+
minimum, HTTP basic auth; it shows who asked what.
|
|
137
|
+
3. **The assistants may only talk to the proxy.** Managed client policy
|
|
138
|
+
(Claude Code managed settings, Claude Enterprise connectors, Copilot and
|
|
139
|
+
Cursor org policies) allow-lists `https://mcp.example.com/mcp` and nothing
|
|
140
|
+
else.
|
|
141
|
+
|
|
142
|
+
Connecting Claude (claude.ai): Settings → Connectors → Add custom connector →
|
|
143
|
+
URL `https://mcp.example.com/mcp`. Claude discovers the sign-in from the
|
|
144
|
+
proxy's OAuth metadata, registers itself, and sends you to `/signin`. From then
|
|
145
|
+
on every question Claude asks on your behalf passes the policy.
|
|
146
|
+
|
|
147
|
+
Sample handbook: `samples/northwind-handbook.docx`; `coc.yaml` maps to its
|
|
148
|
+
clauses 7.1 to 7.11 one to one (7.4 and 7.12 are not enforceable at a data
|
|
149
|
+
proxy). `aggrete-ingest samples/northwind-handbook.docx` reproduces it.
|
|
150
|
+
|
|
151
|
+
## Serving it to a whole company: streamable HTTP + OAuth
|
|
152
|
+
|
|
153
|
+
stdio is for one laptop. For everyone else, run Aggrete as a service and let
|
|
154
|
+
identity come from the token:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
python -m aggrete.proxy --config proxy.config.yaml --transport streamable-http --host 0.0.0.0 --port 8080
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
HTTP mode refuses to start without an `auth:` block. In `jwt` mode it validates
|
|
161
|
+
bearer JWTs from your IdP (issuer, audience, expiry, signature via JWKS,
|
|
162
|
+
required scopes) and derives the user from the `email` claim. Configurable
|
|
163
|
+
with `identity_claim`. Every request without a valid token is a 401 with an
|
|
164
|
+
RFC 9728 `WWW-Authenticate` pointer, and the `user:` line in the config is
|
|
165
|
+
ignored entirely. `builtin` mode is a small OAuth server inside the proxy (dynamic client
|
|
166
|
+
registration, a sign-in page, passcodes from the environment) for teams with
|
|
167
|
+
no IdP yet. `static` mode (fixed tokens) exists for development and the
|
|
168
|
+
test-suite. The accumulator keys state on the token identity, so the same
|
|
169
|
+
person hitting Aggrete from Claude Code, Claude.ai and Cursor shares one
|
|
170
|
+
history. Which is the point.
|
|
171
|
+
|
|
172
|
+
Register it in a client as a remote MCP server at `https://<host>/mcp` with
|
|
173
|
+
the bearer token your IdP issues; keep the connectors themselves reachable
|
|
174
|
+
only from the Aggrete host.
|
|
175
|
+
|
|
176
|
+
## Inside a gateway you already run
|
|
177
|
+
|
|
178
|
+
If agentgateway, IBM ContextForge, Kong or your own gateway is already the
|
|
179
|
+
control plane, don't add a second one. Embed Aggrete:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from aggrete.plugin import PolicyHook, AggreteMiddleware
|
|
183
|
+
|
|
184
|
+
hook = PolicyHook("coc.yaml", domains={"hr__*": "hr-personnel", "ops__*": "ops-rota"},
|
|
185
|
+
store=RedisStore(redis_client))
|
|
186
|
+
# as two calls from your plugin system
|
|
187
|
+
v = hook.before(user, tool) # v.allow, v.message (clause + remediation)
|
|
188
|
+
v = hook.after(user, tool, result_text) # records entities, re-evaluates
|
|
189
|
+
# or as ASGI middleware around any MCP server that answers in JSON
|
|
190
|
+
app = AggreteMiddleware(app, hook, identity=lambda scope: scope["state"]["user"])
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Identity is a callable over the request, so it composes with whatever auth
|
|
194
|
+
the host performs. The middleware refuses at pre-call without forwarding and
|
|
195
|
+
inspects JSON tools/call results for post-call recording.
|
|
196
|
+
|
|
197
|
+
## Ways to deploy
|
|
198
|
+
|
|
199
|
+
| Who | How |
|
|
200
|
+
|---|---|
|
|
201
|
+
| One developer | `uvx aggrete --config proxy.config.yaml` (PyPI) or the `.mcp.json` in this repo |
|
|
202
|
+
| A team | `docker run ghcr.io/cjohannsen81/aggrete` with `/etc/aggrete` mounted, or `helm install aggrete deploy/helm/aggrete` (bundled Redis, JWT auth, Ingress) |
|
|
203
|
+
| A company | Helm/Docker behind your IdP, then make `https://aggrete.<corp>/mcp` the *only* MCP server your assistant policies allow (Claude Code managed settings, Claude Enterprise connectors, Copilot/Cursor org policies), with connectors network-restricted to the Aggrete hosts |
|
|
204
|
+
| Existing gateway | `aggrete.plugin` (above) |
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
## Putting a real system behind the proxy: Google Drive
|
|
208
|
+
|
|
209
|
+
`aggrete/connectors/drive.py` is a Drive upstream the proxy runs itself. How
|
|
210
|
+
it is done, in the order you do it:
|
|
211
|
+
|
|
212
|
+
1. **A service account, not a person.** In Google Cloud: enable the Drive API,
|
|
213
|
+
create a service account (say `aggrete-drive`), download its JSON key. The
|
|
214
|
+
proxy holds the key; nobody's personal Google login is involved, which is
|
|
215
|
+
what makes the proxy the only road.
|
|
216
|
+
2. **Share the folders, read only.** In Drive, create a root folder (say
|
|
217
|
+
`Northwind`) with one subfolder per kind of material (`Restructuring plan`,
|
|
218
|
+
`Legal hold`, `Team documents`) and share the root with the service account
|
|
219
|
+
email as **Viewer**. Service accounts own nothing; they only see what is
|
|
220
|
+
shared with them.
|
|
221
|
+
3. **One tool pair per folder.** The connector lists the root's subfolders and
|
|
222
|
+
exposes `search_<folder>` and `read_<folder>` for each, so the policy can
|
|
223
|
+
name folders:
|
|
224
|
+
```yaml
|
|
225
|
+
upstreams:
|
|
226
|
+
drive: {command: python3, args: [-m, aggrete.connectors.drive, --credentials, /opt/aggrete/drive-sa.json, --root, Northwind]}
|
|
227
|
+
domains:
|
|
228
|
+
"drive__*_restructuring_plan": restructuring-plan # clause 7.9: embargo until announced
|
|
229
|
+
"drive__*_legal_hold": legal-hold # clause 7.3: never for assistants
|
|
230
|
+
"drive__*": drive-general
|
|
231
|
+
```
|
|
232
|
+
4. **Results name people.** Every file comes back with `owner_email` and
|
|
233
|
+
`editor_email`, so the policy's tallies and joins work on Drive results
|
|
234
|
+
like on HR records.
|
|
235
|
+
5. **Remove the direct road.** Disable the assistant's native Drive connector
|
|
236
|
+
for governed accounts (Claude Enterprise: managed connectors; personal
|
|
237
|
+
accounts: remove it). Otherwise the assistant has two ways to Drive and the
|
|
238
|
+
policy only sees one.
|
|
239
|
+
|
|
240
|
+
`python -m aggrete.connectors.drive --credentials sa.json --root Northwind --list`
|
|
241
|
+
prints the tools that will be exposed. If the root is not shared yet the
|
|
242
|
+
connector still starts and exposes a single `status` tool that says what is
|
|
243
|
+
missing, so the proxy never fails to boot because of Drive.
|
|
244
|
+
|
|
245
|
+
## Starting from the document you already have
|
|
246
|
+
|
|
247
|
+
`aggrete/ingest.py` turns a code-of-conduct document into a draft `coc.yaml`:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
python -m aggrete.ingest handbook.pdf --domains proxy.config.yaml -o coc.draft.yaml
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
PDFs go to the model as native document blocks; DOCX, Markdown and text as
|
|
254
|
+
text. The model proposes rules in the exact `coc.yaml` schema with clause text
|
|
255
|
+
verbatim, every action forced to `alert`, and each rule's own tests are run
|
|
256
|
+
through the real `Engine` before the file is written. A draft that fails its
|
|
257
|
+
tests is rejected. Clauses no data proxy can enforce (tone, harassment,
|
|
258
|
+
expenses) are listed separately with the reason. Model set by `AGGRETE_INGEST_MODEL`. Needs `ANTHROPIC_API_KEY`
|
|
259
|
+
or an `ant auth login` profile.
|
|
260
|
+
|
|
261
|
+
## Purpose binding
|
|
262
|
+
|
|
263
|
+
A permanent block gets routed around. `engine.grant_purpose(user, rule_id,
|
|
264
|
+
purpose, ttl_s)` opens a scoped window and stamps every retrieval made under it
|
|
265
|
+
with the stated purpose. Wire it to an approval workflow owned by the clause
|
|
266
|
+
owner named in the rule.
|
|
267
|
+
|
|
268
|
+
## Honest limitations
|
|
269
|
+
|
|
270
|
+
- **Entity extraction is the weak point.** `entities.py` works on stable IDs and
|
|
271
|
+
emails. Tune `IDENTIFIER_KEYS` against your own connectors before trusting any
|
|
272
|
+
threshold, or Layer 4 will either never fire or fire constantly.
|
|
273
|
+
- **Post-call denial redacts, it does not un-fetch.** The data left the upstream.
|
|
274
|
+
Prefer rules that can be decided pre-call.
|
|
275
|
+
- **stdio identity is advisory.** The user is whoever launched the process and the
|
|
276
|
+
config is user-editable. Real enforcement needs streamable HTTP with OAuth, the
|
|
277
|
+
subject taken from the token, and IdP-level blocking of direct connector grants
|
|
278
|
+
so this proxy is the only path.
|
|
279
|
+
- **Aggregation cannot be solved, only narrowed.** A user who spaces requests
|
|
280
|
+
beyond the window, or paraphrases across systems this proxy doesn't front, gets
|
|
281
|
+
through. This raises the cost and creates the audit trail; it is not a ceiling.
|
|
282
|
+
- **Not a gateway.** No multi-tenancy, no token vault, no HA. For production,
|
|
283
|
+
port this policy engine onto agentgateway or IBM ContextForge as a plugin
|
|
284
|
+
rather than running it as your control plane.
|
|
File without changes
|