stacktrace-cli 0.14.0__py3-none-any.whl → 0.16.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/correlate/composition.py +9 -0
- stacktrace/cost/evidence.py +0 -10
- stacktrace/daemon/cli.py +17 -4
- stacktrace/daemon/config.py +38 -0
- stacktrace/daemon/fleet.py +4 -1
- stacktrace/daemon/gateway.py +153 -0
- stacktrace/daemon/gateway_policy.py +161 -0
- stacktrace/daemon/policy_collect.py +90 -5
- stacktrace/daemon/policy_runtime.py +116 -28
- stacktrace/daemon/runtime.py +20 -1
- stacktrace/detector/prompts/jev-v3/stacktrace-infra-unsafe-action.json +1 -1
- 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 +10 -5
- stacktrace/remote/detect_payload.py +14 -0
- stacktrace/remote/policy.py +17 -1
- stacktrace/remote/policy_localize.py +211 -0
- stacktrace/remote/policy_worker.py +14 -1
- stacktrace/remote/redact.py +5 -1
- stacktrace/remote/upload_contract.py +61 -1
- stacktrace/sessions/entrypoint.py +8 -6
- {stacktrace_cli-0.14.0.dist-info → stacktrace_cli-0.16.0.dist-info}/METADATA +8 -7
- {stacktrace_cli-0.14.0.dist-info → stacktrace_cli-0.16.0.dist-info}/RECORD +29 -25
- {stacktrace_cli-0.14.0.dist-info → stacktrace_cli-0.16.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.14.0.dist-info → stacktrace_cli-0.16.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py
CHANGED
|
@@ -266,6 +266,15 @@ def _declared_builtins(document: dict[str, Any], name: str) -> frozenset[str]:
|
|
|
266
266
|
return frozenset(names)
|
|
267
267
|
|
|
268
268
|
|
|
269
|
+
def declared_builtin_skills(document: dict[str, Any]) -> frozenset[str]:
|
|
270
|
+
"""The skills a BOM's agent ships, read as `composition_from_bom` reads them.
|
|
271
|
+
|
|
272
|
+
Hook admission asks this of a freshly collected BOM, so that both readers
|
|
273
|
+
of `openaca:builtin_skills` refuse the same malformed values.
|
|
274
|
+
"""
|
|
275
|
+
return _declared_builtins(document, _BUILTIN_SKILLS_PROPERTY)
|
|
276
|
+
|
|
277
|
+
|
|
269
278
|
def root_property(document: dict[str, Any], name: str) -> str | None:
|
|
270
279
|
"""One `metadata.component` property's value, or `None`.
|
|
271
280
|
|
stacktrace/cost/evidence.py
CHANGED
|
@@ -57,13 +57,3 @@ def tokens(count: int) -> str:
|
|
|
57
57
|
|
|
58
58
|
def _short(value: Decimal) -> str:
|
|
59
59
|
return f"{value:f}".rstrip("0").rstrip(".") if "." in f"{value:f}" else f"{value:f}"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def exact_tokens(count: int) -> str:
|
|
63
|
-
"""A limit someone set, stated exactly: `600M tokens`, `12.5M tokens`,
|
|
64
|
-
`500k tokens`, `1,234 tokens`."""
|
|
65
|
-
if count >= 1_000_000 and count % 100_000 == 0:
|
|
66
|
-
return f"{_short(Decimal(count) / 1_000_000)}M tokens"
|
|
67
|
-
if count >= 1_000 and count % 100 == 0:
|
|
68
|
-
return f"{_short(Decimal(count) / 1_000)}k tokens"
|
|
69
|
-
return f"{count:,} tokens"
|
stacktrace/daemon/cli.py
CHANGED
|
@@ -106,6 +106,12 @@ def _retry_while_starting(
|
|
|
106
106
|
" that fallback: a finding still uploads immediately when the scan commits it."
|
|
107
107
|
" Defaults to STACKTRACE_FLEET_INTERVAL, then 5m.",
|
|
108
108
|
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"--policy-interval",
|
|
111
|
+
default=None,
|
|
112
|
+
help="How often to fetch Fleet policy. Minimum 1s."
|
|
113
|
+
" Defaults to STACKTRACE_POLICY_INTERVAL, then 1m.",
|
|
114
|
+
)
|
|
109
115
|
@click.option(
|
|
110
116
|
"--webhook-interval",
|
|
111
117
|
default=None,
|
|
@@ -141,6 +147,7 @@ def run_command(
|
|
|
141
147
|
scan_since: str | None,
|
|
142
148
|
agent_kinds: tuple[str, ...],
|
|
143
149
|
fleet_interval: str | None,
|
|
150
|
+
policy_interval: str | None,
|
|
144
151
|
webhook_interval: str | None,
|
|
145
152
|
bom_interval: str | None,
|
|
146
153
|
reasoning: bool,
|
|
@@ -168,6 +175,7 @@ def run_command(
|
|
|
168
175
|
scan_since=scan_since,
|
|
169
176
|
agent_kinds=agent_kinds,
|
|
170
177
|
fleet_interval=fleet_interval,
|
|
178
|
+
policy_interval=policy_interval,
|
|
171
179
|
webhook_interval=webhook_interval,
|
|
172
180
|
bom_interval=bom_interval,
|
|
173
181
|
reasoning=reasoning_flag,
|
|
@@ -203,10 +211,10 @@ def run_command(
|
|
|
203
211
|
def _reject_unobservable_kinds(config: DaemonConfig) -> None:
|
|
204
212
|
"""A kind with no observer would scan nothing, silently.
|
|
205
213
|
|
|
206
|
-
Fails at startup rather than at the first tick:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
Fails at startup rather than at the first tick: a kind OpenACA builds a
|
|
215
|
+
BOM for can still be one OpenAIDR reads no sessions of, and a daemon
|
|
216
|
+
quietly finding nothing for one is the failure this refusal exists to make
|
|
217
|
+
loud. What can be observed is OpenAIDR's answer (ADR-0107).
|
|
210
218
|
"""
|
|
211
219
|
from stacktrace.sessions.access import observable_kinds
|
|
212
220
|
|
|
@@ -518,6 +526,11 @@ def start(reasoning: bool) -> None:
|
|
|
518
526
|
os.environ.pop(_JEV_KEY_ENV, None)
|
|
519
527
|
os.environ["STACKTRACE_REASONING"] = "false"
|
|
520
528
|
|
|
529
|
+
try:
|
|
530
|
+
DaemonConfig.resolve()
|
|
531
|
+
except ValueError as error:
|
|
532
|
+
raise click.ClickException(str(error)) from error
|
|
533
|
+
|
|
521
534
|
warning = ensure_internal_saved()
|
|
522
535
|
if warning is not None:
|
|
523
536
|
click.echo(f"stacktrace: {warning}", err=True)
|
stacktrace/daemon/config.py
CHANGED
|
@@ -26,12 +26,17 @@ SETTINGS: tuple[tuple[str, str, str], ...] = (
|
|
|
26
26
|
("scan_since", "STACKTRACE_SCAN_SINCE", "3d"),
|
|
27
27
|
("agent_kinds", "STACKTRACE_AGENT_KIND", "all"),
|
|
28
28
|
("fleet_interval", "STACKTRACE_FLEET_INTERVAL", "5m"),
|
|
29
|
+
("policy_interval", "STACKTRACE_POLICY_INTERVAL", "1m"),
|
|
29
30
|
("webhook_interval", "STACKTRACE_WEBHOOK_INTERVAL", "1m"),
|
|
30
31
|
("bom_interval", "STACKTRACE_BOM_INTERVAL", "6h"),
|
|
31
32
|
# ADR-0081: opt-in only. A detached `start` inherits this the same way it
|
|
32
33
|
# already inherits TYPESAFE_API_KEY -- by setting the environment
|
|
33
34
|
# variable before forking, not by re-typing the flag.
|
|
34
35
|
("reasoning", "STACKTRACE_REASONING", "false"),
|
|
36
|
+
# ADR-0151: off unless a gateway on this host asks the daemon. Set only
|
|
37
|
+
# through the environment; `daemon run` gains no flag for it.
|
|
38
|
+
("gateway_listen", "STACKTRACE_GATEWAY_LISTEN", "off"),
|
|
39
|
+
("gateway_mode", "STACKTRACE_GATEWAY_MODE", "enforce"),
|
|
35
40
|
)
|
|
36
41
|
|
|
37
42
|
_UNITS: dict[str, int] = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
@@ -131,6 +136,22 @@ def parse_reasoning(value: str) -> bool:
|
|
|
131
136
|
return normalized == "true"
|
|
132
137
|
|
|
133
138
|
|
|
139
|
+
def parse_listen(value: str) -> tuple[str, int] | None:
|
|
140
|
+
"""`off`, or `host:port` for the gateway listener (ADR-0151)."""
|
|
141
|
+
if value == "off":
|
|
142
|
+
return None
|
|
143
|
+
host, _, port = value.rpartition(":")
|
|
144
|
+
if not host or not port.isdigit() or not 0 <= int(port) <= 65535:
|
|
145
|
+
raise ValueError(f"gateway listen address must be 'off' or host:port, not {value!r}")
|
|
146
|
+
return host, int(port)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_gateway_mode(value: str) -> str:
|
|
150
|
+
if value not in ("enforce", "observe"):
|
|
151
|
+
raise ValueError(f"gateway mode must be 'enforce' or 'observe', not {value!r}")
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
134
155
|
@dataclass(frozen=True)
|
|
135
156
|
class Setting:
|
|
136
157
|
"""One resolved value, the text it was resolved from, and where that came from."""
|
|
@@ -147,9 +168,12 @@ class DaemonConfig:
|
|
|
147
168
|
scan_since: Setting
|
|
148
169
|
agent_kinds: Setting
|
|
149
170
|
fleet_interval: Setting
|
|
171
|
+
policy_interval: Setting
|
|
150
172
|
webhook_interval: Setting
|
|
151
173
|
bom_interval: Setting
|
|
152
174
|
reasoning: Setting
|
|
175
|
+
gateway_listen: Setting
|
|
176
|
+
gateway_mode: Setting
|
|
153
177
|
|
|
154
178
|
@property
|
|
155
179
|
def scan_interval_seconds(self) -> float:
|
|
@@ -163,6 +187,13 @@ class DaemonConfig:
|
|
|
163
187
|
def fleet_interval_seconds(self) -> float:
|
|
164
188
|
return parse_duration(self.fleet_interval.value)
|
|
165
189
|
|
|
190
|
+
@property
|
|
191
|
+
def policy_interval_seconds(self) -> float:
|
|
192
|
+
seconds = parse_duration(self.policy_interval.value)
|
|
193
|
+
if seconds == 0:
|
|
194
|
+
raise ValueError("policy interval must be at least 1s")
|
|
195
|
+
return seconds
|
|
196
|
+
|
|
166
197
|
@property
|
|
167
198
|
def webhook_interval_seconds(self) -> float:
|
|
168
199
|
return parse_duration(self.webhook_interval.value)
|
|
@@ -228,6 +259,7 @@ class DaemonConfig:
|
|
|
228
259
|
scan_since: str | None = None,
|
|
229
260
|
agent_kinds: Sequence[str] = (),
|
|
230
261
|
fleet_interval: str | None = None,
|
|
262
|
+
policy_interval: str | None = None,
|
|
231
263
|
webhook_interval: str | None = None,
|
|
232
264
|
bom_interval: str | None = None,
|
|
233
265
|
reasoning: bool | None = None,
|
|
@@ -246,9 +278,12 @@ class DaemonConfig:
|
|
|
246
278
|
"scan_since": scan_since,
|
|
247
279
|
"agent_kinds": ",".join(agent_kinds) if agent_kinds else None,
|
|
248
280
|
"fleet_interval": fleet_interval,
|
|
281
|
+
"policy_interval": policy_interval,
|
|
249
282
|
"webhook_interval": webhook_interval,
|
|
250
283
|
"bom_interval": bom_interval,
|
|
251
284
|
"reasoning": None if reasoning is None else ("true" if reasoning else "false"),
|
|
285
|
+
"gateway_listen": None,
|
|
286
|
+
"gateway_mode": None,
|
|
252
287
|
}
|
|
253
288
|
resolved: dict[str, Setting] = {}
|
|
254
289
|
for name, variable, default in SETTINGS:
|
|
@@ -271,9 +306,12 @@ class DaemonConfig:
|
|
|
271
306
|
config.scan_interval_seconds,
|
|
272
307
|
config.scan_since_window,
|
|
273
308
|
config.fleet_interval_seconds,
|
|
309
|
+
config.policy_interval_seconds,
|
|
274
310
|
config.webhook_interval_seconds,
|
|
275
311
|
config.bom_interval_seconds,
|
|
276
312
|
config.reasoning_enabled,
|
|
313
|
+
parse_listen(config.gateway_listen.value),
|
|
314
|
+
parse_gateway_mode(config.gateway_mode.value),
|
|
277
315
|
)
|
|
278
316
|
if not config.kinds:
|
|
279
317
|
raise ValueError("--agent-kind must name at least one agent kind")
|
stacktrace/daemon/fleet.py
CHANGED
|
@@ -407,7 +407,10 @@ class FleetSubscriber:
|
|
|
407
407
|
self._run_names[run_id] = self.store.run_names(agent_kind, run_id)
|
|
408
408
|
names = self._run_names[run_id]
|
|
409
409
|
if names is None:
|
|
410
|
-
|
|
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
|
|
411
414
|
signed = self._signed_in
|
|
412
415
|
same = signed is not None and names.account is not None and names.account == signed[0]
|
|
413
416
|
person = signed[1] if same and signed is not None else None
|
|
@@ -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
|