aggrete 0.1.0__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.
aggrete/__init__.py ADDED
File without changes
aggrete/accumulator.py ADDED
@@ -0,0 +1,111 @@
1
+ """Layer 4 state: what each user has already pulled, across every connector.
2
+
3
+ Keyed to the *user*, not the session. A new chat must not reset the budget.
4
+ MemoryStore is for tests and single-process runs; RedisStore is what you
5
+ deploy, because the whole point is state shared across clients and gateways.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Iterable, Protocol
12
+
13
+
14
+ def parse_window(w: str | int) -> int:
15
+ if isinstance(w, int):
16
+ return w
17
+ units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
18
+ return int(w[:-1]) * units[w[-1]]
19
+
20
+
21
+ class Store(Protocol):
22
+ def record(self, user: str, domain: str, entities: Iterable[str], ttl: int) -> None: ...
23
+ def entities(self, user: str, domain: str) -> set[str]: ...
24
+ def domains(self, user: str) -> set[str]: ...
25
+ def grant(self, user: str, rule_id: str, ttl: int, purpose: str) -> None: ...
26
+ def granted(self, user: str, rule_id: str) -> str | None: ...
27
+ def reset(self, user: str) -> None: ...
28
+
29
+
30
+ class MemoryStore:
31
+ def __init__(self) -> None:
32
+ self._ents: dict[tuple[str, str], dict[str, float]] = {}
33
+ self._grants: dict[tuple[str, str], tuple[float, str]] = {}
34
+
35
+ def _live(self, key: tuple[str, str]) -> dict[str, float]:
36
+ now = time.time()
37
+ bucket = {e: exp for e, exp in self._ents.get(key, {}).items() if exp > now}
38
+ self._ents[key] = bucket
39
+ return bucket
40
+
41
+ def record(self, user, domain, entities, ttl):
42
+ key = (user, domain)
43
+ bucket = self._live(key)
44
+ exp = time.time() + ttl
45
+ # touching a domain with no entities still marks the domain as seen
46
+ bucket.setdefault("__touched__", exp)
47
+ for e in entities:
48
+ bucket[e] = exp
49
+ self._ents[key] = bucket
50
+
51
+ def entities(self, user, domain):
52
+ return {e for e in self._live((user, domain)) if e != "__touched__"}
53
+
54
+ def domains(self, user):
55
+ return {d for (u, d) in list(self._ents) if u == user and self._live((u, d))}
56
+
57
+ def grant(self, user, rule_id, ttl, purpose):
58
+ self._grants[(user, rule_id)] = (time.time() + ttl, purpose)
59
+
60
+ def granted(self, user, rule_id):
61
+ exp, purpose = self._grants.get((user, rule_id), (0, ""))
62
+ return purpose if exp > time.time() else None
63
+
64
+ def reset(self, user):
65
+ for k in [k for k in self._ents if k[0] == user]:
66
+ del self._ents[k]
67
+ for k in [k for k in self._grants if k[0] == user]:
68
+ del self._grants[k]
69
+
70
+
71
+ class RedisStore:
72
+ """Same contract, backed by Redis sets with TTLs."""
73
+
74
+ def __init__(self, client, prefix: str = "coc"):
75
+ self.r = client
76
+ self.p = prefix
77
+
78
+ def _key(self, user, domain):
79
+ return f"{self.p}:ents:{user}:{domain}"
80
+
81
+ def record(self, user, domain, entities, ttl):
82
+ key = self._key(user, domain)
83
+ pipe = self.r.pipeline()
84
+ pipe.sadd(key, "__touched__", *entities)
85
+ pipe.expire(key, ttl)
86
+ pipe.sadd(f"{self.p}:domains:{user}", domain)
87
+ pipe.expire(f"{self.p}:domains:{user}", ttl)
88
+ pipe.execute()
89
+
90
+ def entities(self, user, domain):
91
+ raw = self.r.smembers(self._key(user, domain))
92
+ return {e.decode() if isinstance(e, bytes) else e for e in raw} - {"__touched__"}
93
+
94
+ def domains(self, user):
95
+ live = set()
96
+ for d in self.r.smembers(f"{self.p}:domains:{user}"):
97
+ d = d.decode() if isinstance(d, bytes) else d
98
+ if self.r.exists(self._key(user, d)):
99
+ live.add(d)
100
+ return live
101
+
102
+ def grant(self, user, rule_id, ttl, purpose):
103
+ self.r.setex(f"{self.p}:grant:{user}:{rule_id}", ttl, purpose)
104
+
105
+ def granted(self, user, rule_id):
106
+ v = self.r.get(f"{self.p}:grant:{user}:{rule_id}")
107
+ return (v.decode() if isinstance(v, bytes) else v) if v else None
108
+
109
+ def reset(self, user):
110
+ for k in self.r.scan_iter(f"{self.p}:*:{user}*"):
111
+ self.r.delete(k)
aggrete/auth.py ADDED
@@ -0,0 +1,96 @@
1
+ """Who is calling. Over HTTP the answer comes from the bearer token, never from config.
2
+
3
+ Two verifiers:
4
+
5
+ - JWTVerifier. Production. Validates RS256/ES256 JWTs from your IdP (Okta,
6
+ Entra, Google, Auth0, Keycloak…) against its JWKS, checks
7
+ issuer/audience/expiry, and derives the user from a claim.
8
+ - StaticTokens. Development and tests. A fixed token → subject map.
9
+
10
+ The identity the policy engine sees is `identity_for(token)`: the configured
11
+ claim (default `email`, falling back to `sub`). Per-user state in the
12
+ accumulator is keyed on it, so pick a claim that is stable across sessions.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from typing import Any
19
+
20
+ import jwt
21
+ from jwt import PyJWKClient
22
+ from mcp.server.auth.provider import AccessToken, TokenVerifier
23
+
24
+ DEFAULT_IDENTITY_CLAIMS = ("email", "preferred_username", "sub")
25
+
26
+
27
+ class StaticTokens(TokenVerifier):
28
+ """`auth: {mode: static, tokens: {<token>: {subject: ..., scopes: [...]}}}`."""
29
+
30
+ def __init__(self, tokens: dict[str, dict[str, Any]]):
31
+ self.tokens = tokens
32
+
33
+ async def verify_token(self, token: str) -> AccessToken | None:
34
+ spec = self.tokens.get(token)
35
+ if not spec:
36
+ return None
37
+ return AccessToken(token=token, client_id=spec.get("client_id", "static"),
38
+ scopes=list(spec.get("scopes", [])), subject=spec["subject"],
39
+ claims={"email": spec.get("email", spec["subject"])})
40
+
41
+
42
+ class JWTVerifier(TokenVerifier):
43
+ """`auth: {mode: jwt, issuer: ..., audience: ..., jwks_url: ... | public_key: ...}`."""
44
+
45
+ def __init__(self, issuer: str, audience: str | list[str], *, jwks_url: str | None = None,
46
+ public_key: str | None = None, algorithms: list[str] | None = None,
47
+ required_scopes: list[str] | None = None, leeway: int = 30):
48
+ if not (jwks_url or public_key):
49
+ jwks_url = issuer.rstrip("/") + "/.well-known/jwks.json"
50
+ self.issuer, self.audience = issuer, audience
51
+ self.jwks = PyJWKClient(jwks_url, cache_keys=True) if jwks_url else None
52
+ self.public_key = public_key
53
+ self.algorithms = algorithms or ["RS256", "ES256", "RS384", "ES384", "RS512", "ES512"]
54
+ self.required_scopes = required_scopes or []
55
+ self.leeway = leeway
56
+
57
+ async def verify_token(self, token: str) -> AccessToken | None:
58
+ try:
59
+ key = self.public_key or self.jwks.get_signing_key_from_jwt(token).key
60
+ claims = jwt.decode(token, key, algorithms=self.algorithms, issuer=self.issuer,
61
+ audience=self.audience, leeway=self.leeway,
62
+ options={"require": ["exp", "iss", "sub"]})
63
+ except jwt.PyJWTError:
64
+ return None
65
+ scopes = claims.get("scope", "")
66
+ scopes = scopes.split() if isinstance(scopes, str) else list(scopes or [])
67
+ if any(s not in scopes for s in self.required_scopes):
68
+ return None
69
+ return AccessToken(token=token, client_id=str(claims.get("client_id") or claims.get("azp") or claims.get("aud")),
70
+ scopes=scopes, expires_at=claims.get("exp"), subject=claims["sub"], claims=claims)
71
+
72
+
73
+ def build_verifier(auth_cfg: dict[str, Any]) -> TokenVerifier:
74
+ mode = auth_cfg.get("mode", "jwt")
75
+ if mode == "static":
76
+ return StaticTokens(auth_cfg["tokens"])
77
+ if mode == "jwt":
78
+ return JWTVerifier(auth_cfg["issuer"], auth_cfg["audience"], jwks_url=auth_cfg.get("jwks_url"),
79
+ public_key=auth_cfg.get("public_key"), algorithms=auth_cfg.get("algorithms"),
80
+ required_scopes=auth_cfg.get("required_scopes"))
81
+ raise ValueError(f"unknown auth mode {mode!r}")
82
+
83
+
84
+ def identity_for(token: AccessToken, claim: str | None = None) -> str:
85
+ claims = token.claims or {}
86
+ order = (claim,) if claim else DEFAULT_IDENTITY_CLAIMS
87
+ for c in order:
88
+ if c and claims.get(c):
89
+ return str(claims[c])
90
+ if token.subject:
91
+ return token.subject
92
+ raise ValueError("token carries no usable identity claim")
93
+
94
+
95
+ def unexpired(token: AccessToken) -> bool:
96
+ return token.expires_at is None or token.expires_at > time.time()
File without changes
@@ -0,0 +1,170 @@
1
+ """Google Drive as an Aggrete upstream, one tool pair per folder.
2
+
3
+ python -m aggrete.connectors.drive --credentials sa.json --root "Northwind"
4
+
5
+ The proxy runs this over stdio and holds the service-account key; people never
6
+ do. Each subfolder of the root becomes two tools, `search_<folder>` and
7
+ `read_<folder>`, so the policy can name folders as domains:
8
+
9
+ domains:
10
+ "drive__*_restructuring_plan": restructuring-plan
11
+ "drive__*_legal_hold": legal-hold
12
+ "drive__*": drive-general
13
+
14
+ Results carry the owner's and last editor's email, which is what the policy
15
+ counts as "people". Read only: the service account needs Viewer on the root.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import asyncio
22
+ import json
23
+ import re
24
+ import time
25
+ from pathlib import Path
26
+
27
+ import httpx2 as httpx
28
+ import jwt
29
+ from mcp.server.mcpserver import MCPServer
30
+
31
+ API = "https://www.googleapis.com/drive/v3"
32
+ SCOPE = "https://www.googleapis.com/auth/drive.readonly"
33
+ EXPORT = {"application/vnd.google-apps.document": "text/plain",
34
+ "application/vnd.google-apps.spreadsheet": "text/csv",
35
+ "application/vnd.google-apps.presentation": "text/plain"}
36
+ FIELDS = "files(id,name,mimeType,modifiedTime,owners(emailAddress,displayName),lastModifyingUser(emailAddress),parents,webViewLink)"
37
+
38
+
39
+ def slug(name: str) -> str:
40
+ return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
41
+
42
+
43
+ class Drive:
44
+ """Minimal Drive v3 client on a service account. No Google SDK: PyJWT + httpx."""
45
+
46
+ def __init__(self, credentials: str | Path):
47
+ self.sa = json.loads(Path(credentials).read_text())
48
+ self._tok, self._exp = None, 0.0
49
+ self.http = httpx.Client(timeout=30)
50
+
51
+ def token(self) -> str:
52
+ if self._tok and time.time() < self._exp - 60:
53
+ return self._tok
54
+ now = int(time.time())
55
+ assertion = jwt.encode({"iss": self.sa["client_email"], "scope": SCOPE, "aud": self.sa["token_uri"],
56
+ "iat": now, "exp": now + 3600}, self.sa["private_key"], algorithm="RS256")
57
+ r = self.http.post(self.sa["token_uri"], data={"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion})
58
+ r.raise_for_status()
59
+ self._tok, self._exp = r.json()["access_token"], time.time() + r.json().get("expires_in", 3600)
60
+ return self._tok
61
+
62
+ def get(self, path: str, **params):
63
+ r = self.http.get(API + path, params=params, headers={"Authorization": f"Bearer {self.token()}"})
64
+ r.raise_for_status()
65
+ return r
66
+
67
+ def list(self, q: str, page_size: int = 20) -> list[dict]:
68
+ return self.get("/files", q=q, fields=FIELDS, pageSize=page_size, supportsAllDrives="true",
69
+ includeItemsFromAllDrives="true").json().get("files", [])
70
+
71
+ def folder_by_name(self, name: str) -> dict | None:
72
+ fs = self.list(f"mimeType='application/vnd.google-apps.folder' and name='{name}' and trashed=false", 5)
73
+ return fs[0] if fs else None
74
+
75
+ def subfolders(self, folder_id: str) -> list[dict]:
76
+ return self.list(f"'{folder_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false", 50)
77
+
78
+ def descendants(self, folder_id: str, depth: int = 3) -> set[str]:
79
+ ids, frontier = {folder_id}, [folder_id]
80
+ for _ in range(depth):
81
+ nxt = []
82
+ for f in frontier:
83
+ for sub in self.subfolders(f):
84
+ if sub["id"] not in ids:
85
+ ids.add(sub["id"]); nxt.append(sub["id"])
86
+ frontier = nxt
87
+ return ids
88
+
89
+ def search(self, folder_id: str, query: str) -> list[dict]:
90
+ out = []
91
+ for fid in self.descendants(folder_id):
92
+ q = f"'{fid}' in parents and trashed=false and mimeType!='application/vnd.google-apps.folder'"
93
+ if query.strip():
94
+ safe = query.replace("'", "\\'")
95
+ q += f" and (fullText contains '{safe}' or name contains '{safe}')"
96
+ out += self.list(q, 20)
97
+ return out
98
+
99
+ def read(self, file_id: str, folder_id: str) -> tuple[dict, str]:
100
+ meta = self.get(f"/files/{file_id}", fields="id,name,mimeType,parents,owners(emailAddress,displayName),lastModifyingUser(emailAddress),webViewLink", supportsAllDrives="true").json()
101
+ if not (set(meta.get("parents") or []) & self.descendants(folder_id)):
102
+ raise PermissionError("that file is not in this folder")
103
+ mime = meta.get("mimeType", "")
104
+ if mime in EXPORT:
105
+ text = self.get(f"/files/{file_id}/export", mimeType=EXPORT[mime]).text
106
+ elif mime.startswith("text/") or mime in ("application/json",):
107
+ text = self.get(f"/files/{file_id}", alt="media", supportsAllDrives="true").text
108
+ else:
109
+ text = f"[{mime}: binary file, {meta.get('name')}; not rendered]"
110
+ return meta, text[:20000]
111
+
112
+
113
+ def build(drive: Drive, root_name: str) -> MCPServer:
114
+ server = MCPServer("drive")
115
+ root = drive.folder_by_name(root_name)
116
+ if not root:
117
+ # Stay up so the proxy starts; tell whoever asks what is missing.
118
+ @server.tool(name="status", description="Why no Drive folders are available yet.")
119
+ def status() -> str:
120
+ return json.dumps({"error": f"No folder named {root_name!r} is shared with {drive.sa['client_email']}. "
121
+ "Share it (Viewer) and restart the proxy."})
122
+ return server
123
+ folders = drive.subfolders(root["id"]) or [root]
124
+
125
+ @server.tool(name="folders", description="List the Google Drive folders you can search here. Call this first when asked about documents, files or anything in Google Drive.")
126
+ def folders_tool() -> str:
127
+ return json.dumps({"drive_folders": [{"name": f["name"], "search_tool": f"search_{slug(f['name'])}", "read_tool": f"read_{slug(f['name'])}"} for f in folders]})
128
+
129
+ for f in folders:
130
+ s = slug(f["name"]); fid = f["id"]; label = f["name"]
131
+
132
+ def make(fid=fid, label=label):
133
+ def search(query: str = "") -> str:
134
+ return json.dumps({"folder": label, "files": [
135
+ {"id": x["id"], "name": x["name"], "type": x.get("mimeType"), "modified": x.get("modifiedTime"),
136
+ "owner_email": (x.get("owners") or [{}])[0].get("emailAddress"),
137
+ "editor_email": (x.get("lastModifyingUser") or {}).get("emailAddress"), "link": x.get("webViewLink")}
138
+ for x in drive.search(fid, query)]})
139
+ def read(file_id: str) -> str:
140
+ meta, text = drive.read(file_id, fid)
141
+ return json.dumps({"folder": label, "name": meta["name"], "owner_email": (meta.get("owners") or [{}])[0].get("emailAddress"),
142
+ "editor_email": (meta.get("lastModifyingUser") or {}).get("emailAddress"), "text": text})
143
+ return search, read
144
+
145
+ search, read = make()
146
+ sdesc = f"Search Google Drive for documents in the '{label}' folder, by words in the title or full text. Use this to look for {label.lower()} in Drive; leave the query empty to list everything in the folder."
147
+ rdesc = f"Read a Google Drive document from the '{label}' folder (Docs, Sheets and Slides come back as text)."
148
+ server.tool(name=f"search_{s}", description=sdesc)(search)
149
+ server.tool(name=f"read_{s}", description=rdesc)(read)
150
+ return server
151
+
152
+
153
+ def main() -> None:
154
+ ap = argparse.ArgumentParser()
155
+ ap.add_argument("--credentials", required=True, help="service-account JSON key")
156
+ ap.add_argument("--root", default="Aggrete", help="name of the shared root folder; its subfolders become tools")
157
+ ap.add_argument("--list", action="store_true", help="print the tools that would be exposed and exit")
158
+ a = ap.parse_args()
159
+ drive = Drive(a.credentials)
160
+ if a.list:
161
+ root = drive.folder_by_name(a.root)
162
+ print("root:", root["name"] if root else None, root["id"] if root else "")
163
+ for f in (drive.subfolders(root["id"]) if root else []):
164
+ print(f" {f['name']!r:32} -> drive__search_{slug(f['name'])}, drive__read_{slug(f['name'])}")
165
+ return
166
+ asyncio.run(build(drive, a.root).run_stdio_async())
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
aggrete/entities.py ADDED
@@ -0,0 +1,69 @@
1
+ """Pull stable person identifiers out of whatever a connector hands back.
2
+
3
+ This function is where this whole design succeeds or fails. Names are not
4
+ identifiers. Prefer source-system IDs, then email, and treat free text as a
5
+ last resort. Tune `IDENTIFIER_KEYS` against your own connectors before trusting
6
+ any threshold you set in coc.yaml.
7
+
8
+ Linking: one JSON object that carries several identifier fields (e.g. both
9
+ `email` and `employee_id`) is ONE person and yields ONE canonical key. Email is
10
+ preferred as the canonical form because it is the identifier most likely to be
11
+ shared across connectors. Cross-domain overlap (COC-HR-004) only works when
12
+ both sides produce the same key. When a record has no email the first ID field
13
+ found (in `ID_KEYS` order) is used instead.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+
21
+ EMAIL_KEYS = ("email", "user_email", "primary_email", "mail", "owner_email", "editor_email", "emailaddress")
22
+ ID_KEYS = ("employee_id", "person_id", "worker_id", "user_id", "assignee_id",
23
+ "owner_id", "sfid", "slack_user_id")
24
+ IDENTIFIER_KEYS = set(EMAIL_KEYS) | set(ID_KEYS)
25
+ EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
26
+
27
+
28
+ def normalize(value: str) -> str:
29
+ v = str(value).strip().lower()
30
+ return f"p:{v}"
31
+
32
+
33
+ def _canonical(record: dict) -> str | None:
34
+ """Return the single canonical key for a record, or None if it has none."""
35
+ lowered = {k.lower(): v for k, v in record.items() if isinstance(v, (str, int))}
36
+ for k in EMAIL_KEYS:
37
+ if k in lowered and str(lowered[k]).strip():
38
+ return normalize(lowered[k])
39
+ for k in ID_KEYS:
40
+ if k in lowered and str(lowered[k]).strip():
41
+ return normalize(lowered[k])
42
+ return None
43
+
44
+
45
+ def from_json(obj, out: set[str]) -> set[str]:
46
+ if isinstance(obj, dict):
47
+ key = _canonical(obj)
48
+ if key:
49
+ out.add(key)
50
+ # Nested objects may describe other people (e.g. "manager": {...}).
51
+ for k, v in obj.items():
52
+ if k.lower() not in IDENTIFIER_KEYS:
53
+ from_json(v, out)
54
+ elif isinstance(obj, list):
55
+ for item in obj:
56
+ from_json(item, out)
57
+ return out
58
+
59
+
60
+ def extract(text: str) -> list[str]:
61
+ """Best-effort extraction from one tool result payload."""
62
+ found: set[str] = set()
63
+ try:
64
+ from_json(json.loads(text), found)
65
+ except (ValueError, TypeError):
66
+ pass
67
+ if not found: # fall back to emails in prose
68
+ found |= {normalize(m) for m in EMAIL_RE.findall(text or "")}
69
+ return sorted(found)