getop 1.0.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.
getop/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """getop — read-only troubleshooting CLI for Google Gemini Enterprise (Discovery Engine)."""
2
+
3
+ __version__ = "1.0.0"
getop/_build.py ADDED
@@ -0,0 +1,3 @@
1
+ """Build metadata generated by hatch_build.py — do not edit."""
2
+
3
+ COMMIT = "ae45f94"
getop/auth.py ADDED
@@ -0,0 +1,180 @@
1
+ """Client factory for getop.
2
+
3
+ This is the ONLY place API clients are constructed. Every client here is used
4
+ strictly read-only: list_*, get_*, entries.list (logging) and
5
+ list_time_series / query (monitoring). getop never calls a mutating RPC.
6
+
7
+ Auth is Application Default Credentials via google.auth.default(). No key files.
8
+ Required caller roles: roles/discoveryengine.viewer, roles/logging.viewer,
9
+ roles/monitoring.viewer.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ from dataclasses import dataclass, field
16
+ from functools import lru_cache
17
+ from typing import Any
18
+
19
+ import google.auth
20
+ from google.api_core.client_options import ClientOptions
21
+
22
+
23
+ def _regional_endpoint(location: str) -> str | None:
24
+ """Discovery Engine regional endpoint, or None for the global default."""
25
+ if location and location != "global":
26
+ return f"{location}-discoveryengine.googleapis.com"
27
+ return None
28
+
29
+
30
+ @dataclass
31
+ class Clients:
32
+ """Lazily constructs read-only clients wired to the right regional endpoint.
33
+
34
+ Builder modules must obtain every client through this object (via
35
+ get_clients()) and must never instantiate google-cloud clients directly.
36
+ """
37
+
38
+ project: str
39
+ location: str = "global"
40
+ quota_project: str | None = None
41
+ _cache: dict[str, Any] = field(default_factory=dict, repr=False)
42
+ # RLock: building a client re-enters _cached for the shared credentials.
43
+ _lock: Any = field(default_factory=threading.RLock, repr=False)
44
+
45
+ def _cached(self, key: str, build: Any) -> Any:
46
+ """Thread-safe lazy construction (doctor runs checks concurrently)."""
47
+ if key not in self._cache:
48
+ with self._lock:
49
+ if key not in self._cache:
50
+ self._cache[key] = build()
51
+ return self._cache[key]
52
+
53
+ @property
54
+ def _credentials(self) -> Any:
55
+ """ADC credentials with the target project as quota project.
56
+
57
+ User ADC has no quota project by default and discoveryengine rejects
58
+ such calls; billing quota against the inspected project matches what
59
+ `gcloud auth application-default set-quota-project` would do.
60
+ """
61
+ def build() -> Any:
62
+ credentials, _ = google.auth.default(
63
+ scopes=["https://www.googleapis.com/auth/cloud-platform"]
64
+ )
65
+ if hasattr(credentials, "with_quota_project"):
66
+ credentials = credentials.with_quota_project(
67
+ self.quota_project or self.project
68
+ )
69
+ return credentials
70
+
71
+ return self._cached("credentials", build)
72
+
73
+ # ---- Discovery Engine -------------------------------------------------
74
+ def discoveryengine(self, client_cls: type) -> Any:
75
+ """Build (once) a Discovery Engine service client of the given class,
76
+ e.g. discoveryengine_v1.EngineServiceClient, with the regional
77
+ api_endpoint set when location != "global".
78
+ """
79
+ key = f"de:{client_cls.__module__}.{client_cls.__qualname__}"
80
+
81
+ def build() -> Any:
82
+ endpoint = _regional_endpoint(self.location)
83
+ options = ClientOptions(api_endpoint=endpoint) if endpoint else None
84
+ return client_cls(credentials=self._credentials, client_options=options)
85
+
86
+ return self._cached(key, build)
87
+
88
+ # ---- Cloud Logging ----------------------------------------------------
89
+ @property
90
+ def logging(self) -> Any:
91
+ """google.cloud.logging_v2.Client scoped to the project (entries.list only)."""
92
+ def build() -> Any:
93
+ from google.cloud import logging_v2
94
+
95
+ return logging_v2.Client(project=self.project, credentials=self._credentials)
96
+
97
+ return self._cached("logging", build)
98
+
99
+ # ---- Cloud Monitoring ---------------------------------------------------
100
+ @property
101
+ def monitoring(self) -> Any:
102
+ """monitoring_v3.MetricServiceClient (list_time_series / descriptors only)."""
103
+ def build() -> Any:
104
+ from google.cloud import monitoring_v3
105
+
106
+ return monitoring_v3.MetricServiceClient(credentials=self._credentials)
107
+
108
+ return self._cached("monitoring", build)
109
+
110
+ # ---- REST fallback (GET only) -------------------------------------------
111
+ def rest_get(
112
+ self,
113
+ path: str,
114
+ params: dict[str, str] | None = None,
115
+ host: str | None = None,
116
+ ) -> dict:
117
+ """HTTP GET against a Google Cloud REST API (Discovery Engine by
118
+ default, regional-aware; pass `host` for other read surfaces such as
119
+ logging.googleapis.com logs.list).
120
+
121
+ For read surfaces the published Python clients don't expose
122
+ (e.g. v1alpha dataConnector / assistants agents). GET only — this
123
+ helper cannot issue mutating requests.
124
+
125
+ `path` is the versioned resource path, e.g.
126
+ "v1alpha/projects/p/locations/global/collections/default_collection/dataConnector".
127
+ """
128
+ def build() -> Any:
129
+ from google.auth.transport.requests import AuthorizedSession
130
+
131
+ return AuthorizedSession(self._credentials)
132
+
133
+ session = self._cached("session", build)
134
+ if host is None:
135
+ host = _regional_endpoint(self.location) or "discoveryengine.googleapis.com"
136
+ resp = session.get(
137
+ f"https://{host}/{path.lstrip('/')}", params=params or {}, timeout=60
138
+ )
139
+ resp.raise_for_status()
140
+ return resp.json()
141
+
142
+ # ---- Resource-name helpers ---------------------------------------------
143
+ @property
144
+ def collection_path(self) -> str:
145
+ return (
146
+ f"projects/{self.project}/locations/{self.location}"
147
+ f"/collections/default_collection"
148
+ )
149
+
150
+ @property
151
+ def monitoring_project_path(self) -> str:
152
+ return f"projects/{self.project}"
153
+
154
+
155
+ @lru_cache(maxsize=None)
156
+ def _adc_project() -> str | None:
157
+ _, project = google.auth.default()
158
+ return project
159
+
160
+
161
+ def get_clients(
162
+ project: str | None,
163
+ location: str = "global",
164
+ quota_project: str | None = None,
165
+ ) -> Clients:
166
+ """Return the shared read-only client factory.
167
+
168
+ If project is None, falls back to the ADC default project. quota_project
169
+ lets user-credential callers bill API quota to a project they hold
170
+ serviceusage.services.use on when they lack it on the target project.
171
+ """
172
+ resolved = project or _adc_project()
173
+ if not resolved:
174
+ raise SystemExit(
175
+ "No project specified and ADC has no default project. "
176
+ "Pass --project or run `gcloud auth application-default login`."
177
+ )
178
+ return Clients(
179
+ project=resolved, location=location or "global", quota_project=quota_project
180
+ )
@@ -0,0 +1 @@
1
+ """getop command implementations: ls.py, logs.py, stats.py."""
@@ -0,0 +1,349 @@
1
+ """getop armor — surface Model Armor screening violations.
2
+
3
+ Reads the Model Armor sanitize-operations log
4
+ (modelarmor.googleapis.com/sanitize_operations), which records every
5
+ prompt/response screened by the Model Armor template wired into the Gemini
6
+ Enterprise app, and the per-filter verdict. By default only violations
7
+ (filterMatchState = MATCH_FOUND) are shown. Read-only: entries.list only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+
14
+ import typer
15
+
16
+ from getop import render
17
+ from getop.commands.logs import _print_empty_hint, _snippet, collect_entries
18
+ from getop.duration import since_rfc3339
19
+
20
+ _ARMOR_LOG_ID = "modelarmor.googleapis.com%2Fsanitize_operations"
21
+
22
+ # filter-group key -> the *FilterResult wrapper key Model Armor nests under it.
23
+ _FILTER_KEYS = {
24
+ "pi_and_jailbreak": "piAndJailbreakFilterResult",
25
+ "rai": "raiFilterResult",
26
+ "csam": "csamFilterFilterResult",
27
+ "malicious_uris": "maliciousUriFilterResult",
28
+ }
29
+
30
+
31
+ def armor_filter(project: str, since: str, matched_only: bool) -> str:
32
+ """Cloud Logging filter for the Model Armor sanitize-operations log.
33
+
34
+ matched_only restricts to violations; severity is INFO even on a match,
35
+ so the match state is the only reliable discriminator.
36
+ """
37
+ clauses = armor_base_clauses(project, matched_only)
38
+ clauses.append(f'timestamp>="{since_rfc3339(since)}"')
39
+ return "\n".join(clauses)
40
+
41
+
42
+ def armor_base_clauses(project: str, matched_only: bool) -> list[str]:
43
+ clauses = [f'logName="projects/{project}/logs/{_ARMOR_LOG_ID}"']
44
+ if matched_only:
45
+ clauses.append('jsonPayload.sanitizationResult.filterMatchState="MATCH_FOUND"')
46
+ return clauses
47
+
48
+
49
+ def _matched_filters(filter_results: Any) -> list[str]:
50
+ """Names (with confidence) of the filters that tripped, e.g.
51
+ ['pi_and_jailbreak(HIGH)', 'rai:dangerous(HIGH)']. RAI expands to its
52
+ matched sub-types; other filters report at the group level."""
53
+ if not isinstance(filter_results, dict):
54
+ return []
55
+ matched: list[str] = []
56
+ for group, inner_key in _FILTER_KEYS.items():
57
+ inner = filter_results.get(group)
58
+ if not isinstance(inner, dict):
59
+ continue
60
+ result = inner.get(inner_key)
61
+ if not isinstance(result, dict):
62
+ continue
63
+ if group == "rai":
64
+ for sub, sub_res in (result.get("raiFilterTypeResults") or {}).items():
65
+ if isinstance(sub_res, dict) and sub_res.get("matchState") == "MATCH_FOUND":
66
+ conf = sub_res.get("confidenceLevel")
67
+ matched.append(f"rai:{sub}({conf})" if conf else f"rai:{sub}")
68
+ continue
69
+ if result.get("matchState") == "MATCH_FOUND":
70
+ conf = result.get("confidenceLevel")
71
+ matched.append(f"{group}({conf})" if conf else group)
72
+ return matched
73
+
74
+
75
+ def _normalize(entry: Any) -> dict:
76
+ """Model Armor sanitize entry → JSON-safe row."""
77
+ from collections.abc import Mapping
78
+
79
+ payload = getattr(entry, "payload", None)
80
+ payload_dict = dict(payload) if isinstance(payload, Mapping) else {}
81
+ result = payload_dict.get("sanitizationResult")
82
+ result = result if isinstance(result, dict) else {}
83
+ op = payload_dict.get("operationType") or ""
84
+ direction = {
85
+ "SANITIZE_USER_PROMPT": "prompt",
86
+ "SANITIZE_MODEL_RESPONSE": "response",
87
+ }.get(op, op)
88
+ resource = getattr(entry, "resource", None)
89
+ labels = dict(getattr(resource, "labels", None) or {})
90
+ ts = getattr(entry, "timestamp", None)
91
+ input_ = payload_dict.get("sanitizationInput")
92
+ return {
93
+ "timestamp": ts.isoformat() if ts is not None else None,
94
+ "direction": direction,
95
+ "match_state": result.get("filterMatchState"),
96
+ "matched_filters": _matched_filters(result.get("filterResults")),
97
+ "content": input_.get("text") if isinstance(input_, dict) else None,
98
+ "template": labels.get("template_id"),
99
+ "location": labels.get("location"),
100
+ "insert_id": getattr(entry, "insert_id", None),
101
+ }
102
+
103
+
104
+ def collect_violations(clients: Any, filter_str: str, limit: int) -> list[dict]:
105
+ from google.cloud import logging_v2
106
+
107
+ entries = clients.logging.list_entries(
108
+ filter_=filter_str, order_by=logging_v2.DESCENDING, max_results=limit
109
+ )
110
+ return [_normalize(e) for e in entries]
111
+
112
+
113
+ # ---- policy (Model Armor template) ------------------------------------------
114
+
115
+
116
+ def _discover_templates(clients: Any, since: str) -> list[tuple[str, str]]:
117
+ """Distinct (location, template_id) pairs actually screening traffic,
118
+ read from the sanitize log's resource labels."""
119
+ from google.cloud import logging_v2
120
+
121
+ filter_str = "\n".join(
122
+ [
123
+ f'logName="projects/{clients.project}/logs/{_ARMOR_LOG_ID}"',
124
+ f'timestamp>="{since_rfc3339(since)}"',
125
+ ]
126
+ )
127
+ seen: list[tuple[str, str]] = []
128
+ for entry in clients.logging.list_entries(
129
+ filter_=filter_str, order_by=logging_v2.DESCENDING, max_results=200
130
+ ):
131
+ labels = dict(getattr(getattr(entry, "resource", None), "labels", None) or {})
132
+ pair = (labels.get("location"), labels.get("template_id"))
133
+ if all(pair) and pair not in seen:
134
+ seen.append(pair) # type: ignore[arg-type]
135
+ return seen # type: ignore[return-value]
136
+
137
+
138
+ def template_rows(filter_config: dict) -> list[tuple[str, str, str]]:
139
+ """(filter, status, detail) rows for a Model Armor filterConfig."""
140
+ rows: list[tuple[str, str, str]] = []
141
+ pi = filter_config.get("piAndJailbreakFilterSettings") or {}
142
+ if pi:
143
+ rows.append(
144
+ (
145
+ "Prompt injection & jailbreak",
146
+ pi.get("filterEnforcement", "?"),
147
+ pi.get("confidenceLevel", ""),
148
+ )
149
+ )
150
+ for rai in (filter_config.get("raiSettings") or {}).get("raiFilters") or []:
151
+ rows.append(
152
+ (
153
+ f"Responsible AI: {rai.get('filterType', '?')}",
154
+ "ENABLED",
155
+ rai.get("confidenceLevel", ""),
156
+ )
157
+ )
158
+ mal = filter_config.get("maliciousUriFilterSettings") or {}
159
+ if mal:
160
+ rows.append(("Malicious URIs", mal.get("filterEnforcement", "?"), ""))
161
+ csam = filter_config.get("csamFilterSettings") or {}
162
+ if csam:
163
+ rows.append(("CSAM", csam.get("filterEnforcement", "?"), ""))
164
+ sdp = filter_config.get("sdpSettings") or {}
165
+ if sdp:
166
+ rows.append(("Sensitive data protection", "CONFIGURED", ""))
167
+ return rows
168
+
169
+
170
+ def collect_policy(clients: Any, since: str) -> list[dict]:
171
+ """Fetch the filter config of every Model Armor template in use."""
172
+ templates: list[dict] = []
173
+ for location, template_id in _discover_templates(clients, since):
174
+ name = f"projects/{clients.project}/locations/{location}/templates/{template_id}"
175
+ data = clients.rest_get(
176
+ f"v1/{name}", host=f"modelarmor.{location}.rep.googleapis.com"
177
+ )
178
+ templates.append(
179
+ {
180
+ "name": data.get("name"),
181
+ "template_id": template_id,
182
+ "location": location,
183
+ "labels": data.get("labels") or {},
184
+ "filter_config": data.get("filterConfig") or {},
185
+ "update_time": data.get("updateTime"),
186
+ }
187
+ )
188
+ return templates
189
+
190
+
191
+ def summarise(rows: list[dict]) -> list[dict]:
192
+ """Aggregate violation rows into per-filter hit counts with an example.
193
+
194
+ A single entry can match several filters, so it counts toward each. The
195
+ confidence suffix is stripped for grouping (pi_and_jailbreak, not
196
+ pi_and_jailbreak(HIGH)). Rows arrive newest-first, so the first example
197
+ kept per filter is the most recent one.
198
+ """
199
+ agg: dict[str, dict] = {}
200
+ for row in rows:
201
+ ts = row.get("timestamp")
202
+ content = row.get("content")
203
+ for f in row.get("matched_filters") or []:
204
+ key = f.split("(", 1)[0]
205
+ a = agg.setdefault(
206
+ key, {"filter": key, "hits": 0, "last_seen": None, "example": None}
207
+ )
208
+ a["hits"] += 1
209
+ if ts and (a["last_seen"] is None or ts > a["last_seen"]):
210
+ a["last_seen"] = ts
211
+ if a["example"] is None and content:
212
+ a["example"] = content
213
+ return sorted(agg.values(), key=lambda a: (-a["hits"], a["filter"]))
214
+
215
+
216
+ def _render_summary(summary: list[dict], since: str) -> Any:
217
+ from rich.text import Text
218
+
219
+ if not summary:
220
+ return Text(f"No Model Armor violations in the last {since}.", style="green")
221
+ rows = [
222
+ (
223
+ s["filter"],
224
+ str(s["hits"]),
225
+ s["last_seen"] or "",
226
+ _snippet(s["example"], 60),
227
+ )
228
+ for s in summary
229
+ ]
230
+ return render.table(
231
+ f"Model Armor hits by filter ({since})",
232
+ ["Filter", "Hits", "Last seen", "Example input"],
233
+ rows,
234
+ )
235
+
236
+
237
+ def _render_table(rows: list[dict], since: str, matched_only: bool) -> Any:
238
+ scope = "violations" if matched_only else "screenings"
239
+ title = f"Model Armor {scope} ({since})"
240
+ table_rows = []
241
+ for row in rows:
242
+ state = row.get("match_state") or ""
243
+ filters = ", ".join(row.get("matched_filters") or []) or (
244
+ "[dim]—[/dim]" if state != "MATCH_FOUND" else ""
245
+ )
246
+ state_styled = (
247
+ f"[bold red]{state}[/bold red]" if state == "MATCH_FOUND" else f"[dim]{state}[/dim]"
248
+ )
249
+ table_rows.append(
250
+ [
251
+ row.get("timestamp"),
252
+ row.get("direction"),
253
+ state_styled,
254
+ filters,
255
+ _snippet(row.get("content"), 60),
256
+ ]
257
+ )
258
+ return render.table(
259
+ title,
260
+ ["Time", "Direction", "Match", "Filters", "Content"],
261
+ table_rows,
262
+ )
263
+
264
+
265
+ def _render_policy(templates: list[dict]) -> Any:
266
+ from rich.console import Group
267
+ from rich.text import Text
268
+
269
+ if not templates:
270
+ return Text(
271
+ "No Model Armor templates found screening this project in the window.",
272
+ style="yellow",
273
+ )
274
+ pieces: list[Any] = []
275
+ for tmpl in templates:
276
+ rows = [
277
+ (name, status, detail)
278
+ for name, status, detail in template_rows(tmpl["filter_config"])
279
+ ]
280
+ pieces.append(
281
+ render.table(
282
+ f"Model Armor policy — {tmpl['template_id']} ({tmpl['location']})",
283
+ ["Filter", "Enforcement", "Confidence"],
284
+ rows or [("[dim]no filters configured[/dim]", "", "")],
285
+ )
286
+ )
287
+ return Group(*pieces)
288
+
289
+
290
+ def armor_command(
291
+ ctx: typer.Context,
292
+ policy: bool = typer.Option(
293
+ False, "--policy", help="Print the configured Model Armor template(s) instead."
294
+ ),
295
+ summary: bool = typer.Option(
296
+ False, "--summary", help="Aggregate violations by filter category."
297
+ ),
298
+ since: str = typer.Option("24h", "--since", help="Look-back window, e.g. 1h, 24h, 7d."),
299
+ all_: bool = typer.Option(
300
+ False, "--all", help="Include screenings that passed (not just violations)."
301
+ ),
302
+ limit: int = typer.Option(50, "--limit", help="Maximum entries to return."),
303
+ as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
304
+ ) -> None:
305
+ """Show Model Armor violations, a per-filter summary, or the policy."""
306
+ from getop.auth import get_clients
307
+
308
+ state = ctx.obj
309
+ clients = get_clients(state.project, state.location, getattr(state, "quota_project", None))
310
+
311
+ if policy:
312
+ # Policy is configuration, not screened content — no banner.
313
+ try:
314
+ templates = collect_policy(clients, since)
315
+ except ValueError as exc:
316
+ render.err_console.print(f"[bold red]Error:[/bold red] {exc}")
317
+ raise typer.Exit(code=1) from None
318
+ render.output(templates, _render_policy(templates), as_json)
319
+ return
320
+
321
+ # Surfaces the screened prompt/response text, so warn first.
322
+ render.warn_banner(
323
+ "Output includes prompt/response content that Model Armor screened."
324
+ )
325
+ matched_only = summary or not all_
326
+
327
+ try:
328
+ filter_str = armor_filter(clients.project, since, matched_only)
329
+ except ValueError as exc:
330
+ render.err_console.print(f"[bold red]Error:[/bold red] {exc}")
331
+ raise typer.Exit(code=1) from None
332
+
333
+ rows = collect_violations(clients, filter_str, limit)
334
+
335
+ if summary:
336
+ aggregates = summarise(rows)
337
+ render.output(aggregates, _render_summary(aggregates, since), as_json)
338
+ return
339
+
340
+ render.output(rows, _render_table(rows, since, matched_only), as_json)
341
+ if not rows:
342
+ _print_empty_hint(
343
+ clients,
344
+ _ARMOR_LOG_ID,
345
+ "Model Armor sanitize-operations",
346
+ "Model Armor may not be configured for this project's Gemini "
347
+ "Enterprise app, or nothing was screened in the window. This log "
348
+ "carries no user identity — use `getop logs user` for that.",
349
+ )