stacktrace-cli 0.13.0__py3-none-any.whl → 0.15.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.
- stacktrace/__init__.py +1 -1
- stacktrace/cost/measures.py +2 -2
- stacktrace/cost/rules.py +1 -1
- stacktrace/daemon/config.py +26 -0
- stacktrace/daemon/costs.py +4 -4
- stacktrace/daemon/fleet.py +29 -1
- stacktrace/daemon/gateway.py +153 -0
- stacktrace/daemon/gateway_policy.py +161 -0
- stacktrace/daemon/policy_collect.py +45 -5
- stacktrace/daemon/policy_runtime.py +93 -25
- stacktrace/daemon/presentation.py +8 -0
- stacktrace/daemon/runtime.py +34 -1
- stacktrace/daemon/store.py +46 -1
- stacktrace/daemon/webhook.py +231 -27
- stacktrace/detector/deterministic.py +550 -525
- stacktrace/detector/rules.py +14 -10
- stacktrace/finding_contract.py +355 -38
- stacktrace/finding_titles.py +9 -20
- stacktrace/gateway/analyzer.py +117 -11
- stacktrace/gateway/check.py +4 -0
- stacktrace/gateway/principal.py +88 -0
- stacktrace/gateway/service.py +29 -4
- stacktrace/gateway/sink.py +129 -15
- stacktrace/hooks/claude.py +13 -5
- stacktrace/hooks/codex.py +84 -0
- stacktrace/monitor/render.py +1 -3
- stacktrace/monitor/site/app.js +29 -2
- stacktrace/monitor/site/styles.css +1 -0
- stacktrace/monitor/verdicts.py +26 -9
- stacktrace/remote/detect_payload.py +34 -2
- stacktrace/remote/hook_policy.py +52 -7
- stacktrace/remote/policy_service.py +0 -21
- stacktrace/remote/policy_show.py +11 -1
- stacktrace/remote/redact.py +5 -1
- stacktrace/remote/upload_contract.py +70 -1
- {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.15.0.dist-info}/METADATA +4 -4
- {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.15.0.dist-info}/RECORD +39 -35
- {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.15.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.15.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py
CHANGED
stacktrace/cost/measures.py
CHANGED
|
@@ -127,8 +127,8 @@ def repeats(
|
|
|
127
127
|
most repeated first.
|
|
128
128
|
|
|
129
129
|
A call that recorded a failure takes no part in any cost measure
|
|
130
|
-
(cost-phase-1 §1): a denied one reached nothing, and a failing one
|
|
131
|
-
|
|
130
|
+
(cost-phase-1 §1): a denied one reached nothing, and a failing one returned
|
|
131
|
+
no result to repeat. A call in `skip` was already cited by another rule.
|
|
132
132
|
"""
|
|
133
133
|
seen: dict[tuple[str | None, str, str, str], list[ToolCallLike]] = {}
|
|
134
134
|
for call in calls:
|
stacktrace/cost/rules.py
CHANGED
|
@@ -244,7 +244,7 @@ class Context:
|
|
|
244
244
|
zone: tzinfo
|
|
245
245
|
snapshot: Snapshot | None = None
|
|
246
246
|
#: Spans another rule already cited, which the repeats reason leaves out
|
|
247
|
-
#: (`
|
|
247
|
+
#: (`blocked-*`): one event is never reported twice.
|
|
248
248
|
cited: frozenset[str] = frozenset()
|
|
249
249
|
#: What correlation placed each call on, by span. A call it does not name
|
|
250
250
|
#: gets a label of ours (`cost/components.py`).
|
stacktrace/daemon/config.py
CHANGED
|
@@ -32,6 +32,10 @@ SETTINGS: tuple[tuple[str, str, str], ...] = (
|
|
|
32
32
|
# already inherits TYPESAFE_API_KEY -- by setting the environment
|
|
33
33
|
# variable before forking, not by re-typing the flag.
|
|
34
34
|
("reasoning", "STACKTRACE_REASONING", "false"),
|
|
35
|
+
# ADR-0151: off unless a gateway on this host asks the daemon. Set only
|
|
36
|
+
# through the environment; `daemon run` gains no flag for it.
|
|
37
|
+
("gateway_listen", "STACKTRACE_GATEWAY_LISTEN", "off"),
|
|
38
|
+
("gateway_mode", "STACKTRACE_GATEWAY_MODE", "enforce"),
|
|
35
39
|
)
|
|
36
40
|
|
|
37
41
|
_UNITS: dict[str, int] = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
@@ -131,6 +135,22 @@ def parse_reasoning(value: str) -> bool:
|
|
|
131
135
|
return normalized == "true"
|
|
132
136
|
|
|
133
137
|
|
|
138
|
+
def parse_listen(value: str) -> tuple[str, int] | None:
|
|
139
|
+
"""`off`, or `host:port` for the gateway listener (ADR-0151)."""
|
|
140
|
+
if value == "off":
|
|
141
|
+
return None
|
|
142
|
+
host, _, port = value.rpartition(":")
|
|
143
|
+
if not host or not port.isdigit() or not 0 <= int(port) <= 65535:
|
|
144
|
+
raise ValueError(f"gateway listen address must be 'off' or host:port, not {value!r}")
|
|
145
|
+
return host, int(port)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_gateway_mode(value: str) -> str:
|
|
149
|
+
if value not in ("enforce", "observe"):
|
|
150
|
+
raise ValueError(f"gateway mode must be 'enforce' or 'observe', not {value!r}")
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
134
154
|
@dataclass(frozen=True)
|
|
135
155
|
class Setting:
|
|
136
156
|
"""One resolved value, the text it was resolved from, and where that came from."""
|
|
@@ -150,6 +170,8 @@ class DaemonConfig:
|
|
|
150
170
|
webhook_interval: Setting
|
|
151
171
|
bom_interval: Setting
|
|
152
172
|
reasoning: Setting
|
|
173
|
+
gateway_listen: Setting
|
|
174
|
+
gateway_mode: Setting
|
|
153
175
|
|
|
154
176
|
@property
|
|
155
177
|
def scan_interval_seconds(self) -> float:
|
|
@@ -249,6 +271,8 @@ class DaemonConfig:
|
|
|
249
271
|
"webhook_interval": webhook_interval,
|
|
250
272
|
"bom_interval": bom_interval,
|
|
251
273
|
"reasoning": None if reasoning is None else ("true" if reasoning else "false"),
|
|
274
|
+
"gateway_listen": None,
|
|
275
|
+
"gateway_mode": None,
|
|
252
276
|
}
|
|
253
277
|
resolved: dict[str, Setting] = {}
|
|
254
278
|
for name, variable, default in SETTINGS:
|
|
@@ -274,6 +298,8 @@ class DaemonConfig:
|
|
|
274
298
|
config.webhook_interval_seconds,
|
|
275
299
|
config.bom_interval_seconds,
|
|
276
300
|
config.reasoning_enabled,
|
|
301
|
+
parse_listen(config.gateway_listen.value),
|
|
302
|
+
parse_gateway_mode(config.gateway_mode.value),
|
|
277
303
|
)
|
|
278
304
|
if not config.kinds:
|
|
279
305
|
raise ValueError("--agent-kind must name at least one agent kind")
|
stacktrace/daemon/costs.py
CHANGED
|
@@ -460,9 +460,9 @@ def summaries_from_rows(rows: Iterable[Mapping[str, Any]]) -> list[Summary]:
|
|
|
460
460
|
]
|
|
461
461
|
|
|
462
462
|
|
|
463
|
-
#: Rules whose citations the repeats reason leaves out: `
|
|
464
|
-
#:
|
|
465
|
-
#:
|
|
463
|
+
#: Rules whose citations the repeats reason leaves out: every `blocked-*` rule,
|
|
464
|
+
#: which together are the reliability family. One event, one report
|
|
465
|
+
#: (cost-phase-1 §1).
|
|
466
466
|
_LOOP_CITERS = RELIABILITY_RULES
|
|
467
467
|
|
|
468
468
|
|
|
@@ -651,7 +651,7 @@ class CostPass:
|
|
|
651
651
|
self._log(misses)
|
|
652
652
|
|
|
653
653
|
def _cited(self, runs: Sequence[Run[Any]]) -> frozenset[str]:
|
|
654
|
-
"""Spans
|
|
654
|
+
"""Spans the `blocked-*` rules cite, which the
|
|
655
655
|
repeats reason leaves out: worked out from each changed session this
|
|
656
656
|
tick, and whatever the store already holds for each run's main
|
|
657
657
|
session."""
|
stacktrace/daemon/fleet.py
CHANGED
|
@@ -24,6 +24,7 @@ from contextlib import suppress
|
|
|
24
24
|
from dataclasses import dataclass
|
|
25
25
|
from datetime import UTC, datetime
|
|
26
26
|
from typing import Any, Protocol
|
|
27
|
+
from urllib.parse import urlsplit
|
|
27
28
|
|
|
28
29
|
import httpx
|
|
29
30
|
|
|
@@ -156,6 +157,17 @@ class DrainReport:
|
|
|
156
157
|
stopped: str | None = None
|
|
157
158
|
|
|
158
159
|
|
|
160
|
+
def _console_origin(url: object) -> str | None:
|
|
161
|
+
"""The scheme and host of Fleet's `dashboard_url`, or None for anything
|
|
162
|
+
that is not an http(s) URL with a host."""
|
|
163
|
+
if not isinstance(url, str):
|
|
164
|
+
return None
|
|
165
|
+
parts = urlsplit(url)
|
|
166
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
167
|
+
return None
|
|
168
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
169
|
+
|
|
170
|
+
|
|
159
171
|
class FleetSubscriber:
|
|
160
172
|
"""Drain `records` by cursor, one request per agent kind."""
|
|
161
173
|
|
|
@@ -223,6 +235,7 @@ class FleetSubscriber:
|
|
|
223
235
|
config = load_remote_config(config_path)
|
|
224
236
|
if config.token is None:
|
|
225
237
|
self.store.clear_cursors()
|
|
238
|
+
self.store.clear_fleet_console()
|
|
226
239
|
return DrainReport(stopped="not configured")
|
|
227
240
|
if not self.store.has_queued_rows():
|
|
228
241
|
# `partitions()` stays non-empty long after its backlog has fully
|
|
@@ -247,6 +260,7 @@ class FleetSubscriber:
|
|
|
247
260
|
config = load_remote_config(config_path)
|
|
248
261
|
if config.token is None:
|
|
249
262
|
self.store.clear_cursors()
|
|
263
|
+
self.store.clear_fleet_console()
|
|
250
264
|
return DrainReport(stopped="not configured")
|
|
251
265
|
token = config.token
|
|
252
266
|
config = _recover_interrupted_asset_transition(config=config, config_path=config_path)
|
|
@@ -272,6 +286,11 @@ class FleetSubscriber:
|
|
|
272
286
|
if asset_id != config.asset_id:
|
|
273
287
|
# No separate lock needed — this whole span already holds it.
|
|
274
288
|
_advance_asset_id(config=config, config_path=config_path, asset_id=asset_id)
|
|
289
|
+
# A new asset id means a new registration — first-ever, or a
|
|
290
|
+
# tenant switch — and either way any cached console address
|
|
291
|
+
# named a prior tenant's Fleet. The next successful upload
|
|
292
|
+
# replaces it; until then, no webhook alert should link to one.
|
|
293
|
+
self.store.clear_fleet_console()
|
|
275
294
|
|
|
276
295
|
uploaded = 0
|
|
277
296
|
parked = 0
|
|
@@ -388,7 +407,10 @@ class FleetSubscriber:
|
|
|
388
407
|
self._run_names[run_id] = self.store.run_names(agent_kind, run_id)
|
|
389
408
|
names = self._run_names[run_id]
|
|
390
409
|
if names is None:
|
|
391
|
-
|
|
410
|
+
# A gateway run has no usage row; its finding names the account
|
|
411
|
+
# the gateway verified, whose card the store keeps (ADR-0153).
|
|
412
|
+
account = session.get("account") if isinstance(session, dict) else None
|
|
413
|
+
return None, None, account if isinstance(account, str) and account else None, None
|
|
392
414
|
signed = self._signed_in
|
|
393
415
|
same = signed is not None and names.account is not None and names.account == signed[0]
|
|
394
416
|
person = signed[1] if same and signed is not None else None
|
|
@@ -602,6 +624,12 @@ class FleetSubscriber:
|
|
|
602
624
|
except RemoteClientError as error:
|
|
603
625
|
raise DrainStopped(f"upload rejected: {error}") from error
|
|
604
626
|
else:
|
|
627
|
+
# Where Fleet's console is, for the link a webhook alert carries
|
|
628
|
+
# (ADR-0147). Fleet names it on every upload; the origin is all
|
|
629
|
+
# that is kept.
|
|
630
|
+
origin = _console_origin(getattr(result, "dashboard_url", None))
|
|
631
|
+
if origin is not None:
|
|
632
|
+
self.store.set_fleet_console(origin)
|
|
605
633
|
sent = len(payload["detections"])
|
|
606
634
|
kept = int(getattr(result, "detections_ingested", sent))
|
|
607
635
|
if kept < sent:
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The daemon's gateway listener: ADR-0119's contract, answered by the daemon (ADR-0151).
|
|
2
|
+
|
|
3
|
+
A gateway on this host (the LiteLLM guardrail, the Envoy Lua filter) asks the
|
|
4
|
+
same `/v1/evaluate` and `/v1/observe` it asks `stacktrace guard serve`. The
|
|
5
|
+
rules, the fail-open behaviour and the reply are the guard's own. What changes
|
|
6
|
+
is where a finding goes: into the daemon's `FindingStore`, so the Fleet drain
|
|
7
|
+
uploads it and the notification sinks hear of it, as for a transcript finding.
|
|
8
|
+
|
|
9
|
+
Off unless `STACKTRACE_GATEWAY_LISTEN` names an address. The private Unix
|
|
10
|
+
socket and its `authorize` message are untouched: a gateway turn is far larger
|
|
11
|
+
than that socket's frame and carries the arguments its contract excludes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from http.server import ThreadingHTTPServer
|
|
21
|
+
|
|
22
|
+
from stacktrace.cost.usage import AccountCard
|
|
23
|
+
from stacktrace.detector.finding import Detection
|
|
24
|
+
from stacktrace.detector.render import detection_json
|
|
25
|
+
from stacktrace.gateway.analyzer import PolicyCheck
|
|
26
|
+
from stacktrace.gateway.principal import Principal, session_key_id
|
|
27
|
+
from stacktrace.gateway.service import Guard, loopback, make_server, serve_in_thread
|
|
28
|
+
from stacktrace.gateway.sink import Recorder, Stamp
|
|
29
|
+
|
|
30
|
+
from .accounts import email_digest
|
|
31
|
+
from .config import DaemonConfig, parse_listen
|
|
32
|
+
from .store import FindingStore, Notification, SessionKey
|
|
33
|
+
from .usage import person_name
|
|
34
|
+
|
|
35
|
+
__all__ = ["parse_listen", "start_gateway"]
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _LogSink:
|
|
41
|
+
"""Verdict, fallback and call records go to the daemon's log; they are content-free."""
|
|
42
|
+
|
|
43
|
+
def write(self, record: dict[str, object]) -> None:
|
|
44
|
+
logger.info("gateway %s", json.dumps(record))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _qualify_spans(payload: dict[str, object], agent_kind: str, bare: str, keyed: str) -> str:
|
|
48
|
+
"""Rename the client's bare session to `agent_kind:keyed` everywhere the finding names it.
|
|
49
|
+
|
|
50
|
+
Fleet refuses a finding whose evidence span, or whose target's session or
|
|
51
|
+
span, addresses another session than its own, so renaming only
|
|
52
|
+
`session.session_id` costs every gateway finding a 422.
|
|
53
|
+
"""
|
|
54
|
+
qualified = f"{agent_kind}:{keyed}"
|
|
55
|
+
|
|
56
|
+
def span(value: object) -> object:
|
|
57
|
+
if isinstance(value, str) and value.rsplit(":", 2)[0] == bare:
|
|
58
|
+
return qualified + value[len(bare) :]
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
for key in ("evidence", "finding_targets"):
|
|
62
|
+
rows = payload.get(key)
|
|
63
|
+
for row in rows if isinstance(rows, list) else ():
|
|
64
|
+
if not isinstance(row, dict):
|
|
65
|
+
continue
|
|
66
|
+
if "span" in row:
|
|
67
|
+
row["span"] = span(row["span"])
|
|
68
|
+
if row.get("session_id") == bare:
|
|
69
|
+
row["session_id"] = qualified
|
|
70
|
+
return qualified
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _card(principal: Principal) -> AccountCard:
|
|
74
|
+
"""The card a gateway principal travels with (ADR-0153). An email keeps
|
|
75
|
+
ADR-0112's digest and name, so its person key is a laptop run's; any
|
|
76
|
+
other id has no address, so no key and no name."""
|
|
77
|
+
email = principal.email()
|
|
78
|
+
name = person_name(None, email)
|
|
79
|
+
return AccountCard(
|
|
80
|
+
account=principal.account_id(),
|
|
81
|
+
email_digest=email_digest(email),
|
|
82
|
+
display_name=name,
|
|
83
|
+
plan=None,
|
|
84
|
+
seen_at=datetime.now(UTC).isoformat(),
|
|
85
|
+
name_from="email" if name else None,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _store_finding(
|
|
90
|
+
store: FindingStore,
|
|
91
|
+
publish: Callable[[Notification], None],
|
|
92
|
+
on_commit: Callable[[], None],
|
|
93
|
+
) -> Callable[[Detection, Stamp], None]:
|
|
94
|
+
def write(detection: Detection, stamp: Stamp) -> None:
|
|
95
|
+
kind = detection.session.agent_kind
|
|
96
|
+
# Keyed by agent kind, session id and principal (ADR-0151), so two
|
|
97
|
+
# people's sessions never merge in the store or in Fleet.
|
|
98
|
+
session = SessionKey(kind, session_key_id(detection.session.session_id, stamp.principal))
|
|
99
|
+
payload = detection_json(detection)
|
|
100
|
+
# The store names a session by the qualified id a transcript's
|
|
101
|
+
# findings carry; a gateway session's id is the client's bare one.
|
|
102
|
+
document = payload["session"]
|
|
103
|
+
assert isinstance(document, dict)
|
|
104
|
+
document["session_id"] = _qualify_spans(
|
|
105
|
+
payload, kind, detection.session.session_id, session.session_id
|
|
106
|
+
)
|
|
107
|
+
payload["actor"] = stamp.actor
|
|
108
|
+
payload["action"] = stamp.action
|
|
109
|
+
payload["decided_by"] = stamp.decided_by
|
|
110
|
+
if stamp.principal is not None:
|
|
111
|
+
# The run's account and its card, as a laptop run's sign-in has
|
|
112
|
+
# them, so Fleet names the person with no new field (ADR-0153).
|
|
113
|
+
document["account"] = stamp.principal.account_id()
|
|
114
|
+
store.put_account_card(kind, _card(stamp.principal))
|
|
115
|
+
notifications = store.record(session, (payload,), underbuilt=None)
|
|
116
|
+
# As a scan commit does, so the finding leaves before the next drain interval.
|
|
117
|
+
on_commit()
|
|
118
|
+
for notification in notifications:
|
|
119
|
+
publish(notification)
|
|
120
|
+
|
|
121
|
+
return write
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def start_gateway(
|
|
125
|
+
config: DaemonConfig,
|
|
126
|
+
store: FindingStore,
|
|
127
|
+
publish: Callable[[Notification], None],
|
|
128
|
+
*,
|
|
129
|
+
token: str | None = None,
|
|
130
|
+
on_commit: Callable[[], None] = lambda: None,
|
|
131
|
+
policy: PolicyCheck | None = None,
|
|
132
|
+
) -> ThreadingHTTPServer | None:
|
|
133
|
+
"""Serve the guard contract on the configured address, or nothing when it is off."""
|
|
134
|
+
address = parse_listen(config.gateway_listen.value)
|
|
135
|
+
if address is None:
|
|
136
|
+
return None
|
|
137
|
+
if token is None and not loopback(address[0]):
|
|
138
|
+
logger.error("gateway listener not started: %s needs STACKTRACE_GUARD_TOKEN", address[0])
|
|
139
|
+
return None
|
|
140
|
+
mode = "observe" if config.gateway_mode.value == "observe" else "enforce"
|
|
141
|
+
recorder = Recorder(
|
|
142
|
+
[_LogSink()], on_finding=_store_finding(store, publish, on_commit), verdicts=True
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
server = make_server(Guard(recorder=recorder, mode=mode, policy=policy), *address, token)
|
|
146
|
+
except OSError as error:
|
|
147
|
+
# The gateway fails open without us; scans and hooks must not stop too.
|
|
148
|
+
logger.error("gateway listener not started on %s:%s: %s", *address, error)
|
|
149
|
+
return None
|
|
150
|
+
serve_in_thread(server)
|
|
151
|
+
host, port = server.server_address[:2]
|
|
152
|
+
logger.info("gateway listener: %s on %s:%s", mode, host, port)
|
|
153
|
+
return server
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Fleet policy at the gateway: the calls in a model response, judged by name (ADR-0151).
|
|
2
|
+
|
|
3
|
+
The hook path resolves a component from local evidence. A gateway sees only the
|
|
4
|
+
names the model sees, so this maps each pending call to a policy subject by
|
|
5
|
+
name, through each agent kind's own naming map, and asks the same `decide`
|
|
6
|
+
the hook path asks for a subject it could not resolve.
|
|
7
|
+
|
|
8
|
+
- A plugin the policy names by `plugin@marketplace` matches its name alone:
|
|
9
|
+
the gateway sees no marketplace. A name both allowed and blocked is
|
|
10
|
+
ambiguous and is not blocked (ADR-0135 decision 4).
|
|
11
|
+
- An MCP server rule names a command or a URL, never a name, so no MCP server
|
|
12
|
+
rule matches at the gateway. Only its category default can.
|
|
13
|
+
- A subject the policy does not name passes, unless the organization turned
|
|
14
|
+
category defaults on for gateways.
|
|
15
|
+
- No policy, or one older than its time to live, passes and is a fallback.
|
|
16
|
+
|
|
17
|
+
Matching by name lets a server renamed in the user's own configuration pass.
|
|
18
|
+
The hooks stay the authority for components; this is the backstop.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from stacktrace.gateway.analyzer import PolicyVerdict
|
|
27
|
+
from stacktrace.hooks.claude import policy_message
|
|
28
|
+
from stacktrace.policy_display import policy_rows
|
|
29
|
+
from stacktrace.sessions.access import GatewayToolCall
|
|
30
|
+
|
|
31
|
+
# `Policy` through the runtime, which is this package's one OpenACA policy importer.
|
|
32
|
+
from .policy_runtime import REFRESH_INTERVAL, Policy, PolicyRuntime, Result, Subject, decide
|
|
33
|
+
|
|
34
|
+
#: How old a fetched policy may be before the gateway stops enforcing it. The
|
|
35
|
+
#: daemon polls Fleet at most every 60 s; an hour is the hook path's evidence
|
|
36
|
+
#: refresh interval, so both paths stop trusting what they hold together.
|
|
37
|
+
POLICY_TTL_S = REFRESH_INTERVAL
|
|
38
|
+
|
|
39
|
+
#: The gateway cannot see which of user, project or local configured a server.
|
|
40
|
+
#: All three are standalone servers under `admission.mcps`, so this one selects
|
|
41
|
+
#: the same category default as the others.
|
|
42
|
+
_STANDALONE = "user"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Inputs:
|
|
47
|
+
"""The policy as the daemon holds it now."""
|
|
48
|
+
|
|
49
|
+
policy: Policy | None
|
|
50
|
+
revision: int | None
|
|
51
|
+
#: Seconds since the last successful Fleet fetch; None before one.
|
|
52
|
+
age_s: float | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def claude_code_subject(call: GatewayToolCall) -> Subject | None:
|
|
56
|
+
"""Claude Code's naming map. Built-in tools are not policy subjects."""
|
|
57
|
+
server = call.mcp_server
|
|
58
|
+
if server is not None:
|
|
59
|
+
# `mcp__plugin_<plugin>_<server>__<tool>` and `mcp__claude_ai_<name>__<tool>`
|
|
60
|
+
# (code.claude.com/docs/en/mcp, /permissions).
|
|
61
|
+
if server.startswith("plugin_"):
|
|
62
|
+
return Subject("mcp_tool", server.removeprefix("plugin_"), "plugin")
|
|
63
|
+
if server.startswith("claude_ai_"):
|
|
64
|
+
return Subject("mcp_tool", server, "claudeai")
|
|
65
|
+
return Subject("mcp_tool", server, _STANDALONE)
|
|
66
|
+
if call.tool_name == "Skill":
|
|
67
|
+
skill = call.arguments.get("skill")
|
|
68
|
+
return Subject("skill", skill, "") if isinstance(skill, str) and skill else None
|
|
69
|
+
if call.tool_name in ("Task", "Agent"):
|
|
70
|
+
agent = call.arguments.get("subagent_type")
|
|
71
|
+
if isinstance(agent, str) and ":" in agent:
|
|
72
|
+
return Subject("plugin_agent", agent, "")
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: Each agent kind names its tools its own way. Claude Code ships first.
|
|
77
|
+
SUBJECTS: dict[str, Callable[[GatewayToolCall], Subject | None]] = {
|
|
78
|
+
"claude-code": claude_code_subject,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sanitized(plugin: str) -> str:
|
|
83
|
+
"""A plugin name as Claude Code spells it inside an MCP tool name."""
|
|
84
|
+
return "".join(c if c.isalnum() or c in "_-" else "_" for c in plugin)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _owned_by(subject: Subject, plugin: str) -> bool:
|
|
88
|
+
if subject.kind == "mcp_tool":
|
|
89
|
+
return subject.source == "plugin" and subject.name.startswith(_sanitized(plugin) + "_")
|
|
90
|
+
return ":" in subject.name and subject.name.partition(":")[0] == plugin
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _by_name(subject: Subject, policy: Policy, revision: int | None) -> Result | None:
|
|
94
|
+
"""The plugin rule that names this subject's plugin, or None when none does, or two disagree."""
|
|
95
|
+
matches: dict[str, str] = {}
|
|
96
|
+
for state, targets in (
|
|
97
|
+
("allowed", policy.plugins.allowed),
|
|
98
|
+
("blocked", policy.plugins.blocked),
|
|
99
|
+
):
|
|
100
|
+
for index, target in enumerate(targets):
|
|
101
|
+
plugin = getattr(target, "plugin", None)
|
|
102
|
+
if plugin is not None and _owned_by(subject, plugin.rpartition("@")[0]):
|
|
103
|
+
matches.setdefault(state, f"admission.plugins.{state}[{index}]")
|
|
104
|
+
if len(matches) != 1:
|
|
105
|
+
return None
|
|
106
|
+
((state, rule),) = matches.items()
|
|
107
|
+
if state == "allowed":
|
|
108
|
+
return Result("allow", "policy_allowed", revision, rule)
|
|
109
|
+
return Result("deny", "policy_blocked", revision, rule, dict(policy_rows(policy))[rule])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GatewayPolicy:
|
|
113
|
+
"""The `PolicyCheck` the daemon's listener runs on each turn's pending calls."""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
read: Callable[[], Inputs],
|
|
118
|
+
*,
|
|
119
|
+
category_defaults: bool = False,
|
|
120
|
+
ttl_s: float = POLICY_TTL_S,
|
|
121
|
+
) -> None:
|
|
122
|
+
self._read = read
|
|
123
|
+
#: The organization's opt-in to category defaults at gateways. Fleet
|
|
124
|
+
#: has no such setting yet, so the daemon passes False.
|
|
125
|
+
self.category_defaults = category_defaults
|
|
126
|
+
self._ttl_s = ttl_s
|
|
127
|
+
|
|
128
|
+
def __call__(self, calls: tuple[GatewayToolCall, ...], agent_kind: str | None) -> PolicyVerdict:
|
|
129
|
+
naming = SUBJECTS.get(agent_kind or "")
|
|
130
|
+
subjects = [s for s in map(naming, calls) if s is not None] if naming else []
|
|
131
|
+
if not subjects:
|
|
132
|
+
return PolicyVerdict()
|
|
133
|
+
inputs = self._read()
|
|
134
|
+
if inputs.policy is None:
|
|
135
|
+
return PolicyVerdict(revision=inputs.revision, fallback="policy_unavailable")
|
|
136
|
+
if inputs.age_s is None or inputs.age_s > self._ttl_s:
|
|
137
|
+
return PolicyVerdict(revision=inputs.revision, fallback="policy_stale")
|
|
138
|
+
for subject in subjects:
|
|
139
|
+
result = _by_name(subject, inputs.policy, inputs.revision)
|
|
140
|
+
if result is None and self.category_defaults:
|
|
141
|
+
result = decide(subject, inputs.policy, inputs.revision)
|
|
142
|
+
if result is not None and result.outcome == "deny":
|
|
143
|
+
return PolicyVerdict(
|
|
144
|
+
deny=True,
|
|
145
|
+
message=policy_message(result.rule, result.detail),
|
|
146
|
+
revision=inputs.revision,
|
|
147
|
+
rule=result.rule,
|
|
148
|
+
)
|
|
149
|
+
return PolicyVerdict(revision=inputs.revision)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def runtime_inputs(runtime: PolicyRuntime) -> Callable[[], Inputs]:
|
|
153
|
+
"""Read the policy the daemon's runtime holds, as the gateway needs it."""
|
|
154
|
+
|
|
155
|
+
def read() -> Inputs:
|
|
156
|
+
with runtime.cache.guard:
|
|
157
|
+
policy, revision = runtime.cache.policy, runtime.cache.state.desired_revision
|
|
158
|
+
refreshed = runtime.refreshed_at
|
|
159
|
+
return Inputs(policy, revision, None if refreshed is None else runtime.clock() - refreshed)
|
|
160
|
+
|
|
161
|
+
return read
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import contextlib
|
|
10
10
|
import hashlib
|
|
11
|
+
import inspect
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
13
14
|
import signal
|
|
@@ -29,8 +30,11 @@ MAX_BUNDLE_BYTES = 2 * 1024 * 1024
|
|
|
29
30
|
COLLECT_TIMEOUT = 45
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
def collector_available() -> bool:
|
|
33
|
-
|
|
33
|
+
def collector_available(agent_kind: str = "claude-code") -> bool:
|
|
34
|
+
collector = getattr(openaca.core, "collect_policy_evidence", None)
|
|
35
|
+
if not callable(collector):
|
|
36
|
+
return False
|
|
37
|
+
return agent_kind != "codex" or "agent_kind" in inspect.signature(collector).parameters
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
def _source(ref: ComponentRef, scope: Scope) -> str | None:
|
|
@@ -90,11 +94,24 @@ def evaluate_bundle(policy: Policy, evidence: Any, scope: Scope) -> tuple[Entry,
|
|
|
90
94
|
name = path[-1].get("name")
|
|
91
95
|
if not isinstance(name, str) or not name or len(name) > 4096:
|
|
92
96
|
continue
|
|
97
|
+
if scope.agent_kind == "codex":
|
|
98
|
+
from stacktrace.hooks.codex import namespace_for_server
|
|
99
|
+
|
|
100
|
+
if component_type != "mcp_server" or ref.extra.get("enabled") is False:
|
|
101
|
+
continue
|
|
102
|
+
# Codex exposes the server namespace for both direct and plugin MCPs.
|
|
103
|
+
# Keep normalized collisions: DecisionCache applies category defaults.
|
|
104
|
+
name = namespace_for_server(name)
|
|
105
|
+
if name is None:
|
|
106
|
+
continue
|
|
93
107
|
blocked = decision.blocked
|
|
94
108
|
rule = next(iter(getattr(decision, "rule_paths", ())), None) if blocked else None
|
|
95
109
|
detail = descriptions.get(rule) if rule is not None else None
|
|
96
110
|
if detail is not None and len(detail) > MAX_RULE_DETAIL:
|
|
97
111
|
detail = detail[: MAX_RULE_DETAIL - 3] + "..."
|
|
112
|
+
if scope.agent_kind == "codex":
|
|
113
|
+
entries.append((Entry("mcp_tool", name, "codex", blocked, rule, detail), 0))
|
|
114
|
+
continue
|
|
98
115
|
owner = decision.subject.ref.name if decision.controlled_by_plugin else None
|
|
99
116
|
source = "plugin" if owner else _source(ref, scope)
|
|
100
117
|
rank = 1 if source in {"project", "local"} and not owner and ":" not in name else 0
|
|
@@ -154,6 +171,7 @@ def collect_bounded(document: dict[str, object], scope: Scope) -> Collected:
|
|
|
154
171
|
request = json.dumps(
|
|
155
172
|
{
|
|
156
173
|
"document": document,
|
|
174
|
+
"agent_kind": scope.agent_kind,
|
|
157
175
|
"config_root": scope.config_root,
|
|
158
176
|
"project_root": scope.project_root,
|
|
159
177
|
"global_config": scope.global_config,
|
|
@@ -188,9 +206,14 @@ def collect_bounded(document: dict[str, object], scope: Scope) -> Collected:
|
|
|
188
206
|
"inventory_digest",
|
|
189
207
|
"vulnerability_run_id",
|
|
190
208
|
"posture_run_id",
|
|
209
|
+
"inventory_gap_count",
|
|
191
210
|
}:
|
|
192
211
|
raise ValueError("policy_evidence_invalid")
|
|
193
212
|
for name, value in identifiers.items():
|
|
213
|
+
if name == "inventory_gap_count":
|
|
214
|
+
if type(value) is not int or not 0 <= value < 2**31:
|
|
215
|
+
raise ValueError("policy_evidence_invalid")
|
|
216
|
+
continue
|
|
194
217
|
if value is None and name != "inventory_digest":
|
|
195
218
|
continue
|
|
196
219
|
if (
|
|
@@ -240,12 +263,21 @@ def main() -> int:
|
|
|
240
263
|
if len(raw) > MAX_BUNDLE_BYTES:
|
|
241
264
|
return 1
|
|
242
265
|
request = json.loads(raw)
|
|
243
|
-
scope = Scope(
|
|
266
|
+
scope = Scope(
|
|
267
|
+
request["config_root"],
|
|
268
|
+
request["project_root"],
|
|
269
|
+
request["global_config"],
|
|
270
|
+
request.get("agent_kind", "claude-code"),
|
|
271
|
+
)
|
|
244
272
|
# This child owns its environment. Select the same native global file
|
|
245
273
|
# as the launching host, without mutating the resident daemon's env.
|
|
246
274
|
root = Path(scope.config_root)
|
|
247
275
|
target: Path | None = root
|
|
248
|
-
if
|
|
276
|
+
if scope.agent_kind == "codex":
|
|
277
|
+
if Path(scope.global_config) != (root / "config.toml").resolve():
|
|
278
|
+
raise ValueError("invalid_global_config")
|
|
279
|
+
os.environ["CODEX_HOME"] = str(root)
|
|
280
|
+
elif Path(scope.global_config) == (root / ".claude.json").resolve():
|
|
249
281
|
os.environ["CLAUDE_CONFIG_DIR"] = str(root)
|
|
250
282
|
elif (
|
|
251
283
|
root == (Path.home() / ".claude").resolve()
|
|
@@ -265,7 +297,14 @@ def main() -> int:
|
|
|
265
297
|
os.dup2(quiet.fileno(), 1)
|
|
266
298
|
os.dup2(quiet.fileno(), 2)
|
|
267
299
|
policy = parse_policy(request["document"])
|
|
268
|
-
|
|
300
|
+
options: dict[str, Any] = (
|
|
301
|
+
{"agent_kind": scope.agent_kind} if scope.agent_kind == "codex" else {}
|
|
302
|
+
)
|
|
303
|
+
if "allow_partial_inventory" in inspect.signature(collector).parameters:
|
|
304
|
+
options["allow_partial_inventory"] = True
|
|
305
|
+
evidence: Any = collector(
|
|
306
|
+
policy, target=target, project=Path(scope.project_root), **options
|
|
307
|
+
)
|
|
269
308
|
digest = inventory_digest(evidence)
|
|
270
309
|
# Both gates use this exact owned bundle. The facade exposes no
|
|
271
310
|
# advisory database revision; never invent one from a timestamp.
|
|
@@ -274,6 +313,7 @@ def main() -> int:
|
|
|
274
313
|
digest,
|
|
275
314
|
run_id if policy.risk_gates.vulnerabilities is not None else None,
|
|
276
315
|
run_id if policy.risk_gates.posture_rule_ids else None,
|
|
316
|
+
len(getattr(evidence, "inventory_gaps", ())),
|
|
277
317
|
)
|
|
278
318
|
paths = sorted(
|
|
279
319
|
{
|