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 +3 -0
- getop/_build.py +3 -0
- getop/auth.py +180 -0
- getop/commands/__init__.py +1 -0
- getop/commands/armor.py +349 -0
- getop/commands/doctor.py +205 -0
- getop/commands/info.py +372 -0
- getop/commands/logs.py +773 -0
- getop/commands/ls.py +459 -0
- getop/commands/stats.py +546 -0
- getop/duration.py +31 -0
- getop/main.py +328 -0
- getop/render.py +87 -0
- getop-1.0.0.dist-info/METADATA +360 -0
- getop-1.0.0.dist-info/RECORD +18 -0
- getop-1.0.0.dist-info/WHEEL +4 -0
- getop-1.0.0.dist-info/entry_points.txt +2 -0
- getop-1.0.0.dist-info/licenses/LICENSE +21 -0
getop/__init__.py
ADDED
getop/_build.py
ADDED
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."""
|
getop/commands/armor.py
ADDED
|
@@ -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
|
+
)
|