stacktrace-cli 0.14.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/daemon/config.py +26 -0
- stacktrace/daemon/fleet.py +4 -1
- stacktrace/daemon/gateway.py +153 -0
- stacktrace/daemon/gateway_policy.py +161 -0
- stacktrace/daemon/policy_runtime.py +61 -21
- stacktrace/daemon/runtime.py +14 -0
- 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 +9 -4
- stacktrace/remote/detect_payload.py +14 -0
- stacktrace/remote/redact.py +5 -1
- stacktrace/remote/upload_contract.py +61 -1
- {stacktrace_cli-0.14.0.dist-info → stacktrace_cli-0.15.0.dist-info}/METADATA +2 -2
- {stacktrace_cli-0.14.0.dist-info → stacktrace_cli-0.15.0.dist-info}/RECORD +20 -17
- {stacktrace_cli-0.14.0.dist-info → stacktrace_cli-0.15.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.14.0.dist-info → stacktrace_cli-0.15.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py
CHANGED
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/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
|
|
@@ -238,30 +238,51 @@ class DecisionCache:
|
|
|
238
238
|
"""Use defaults from the same policy as the active decision table."""
|
|
239
239
|
if policy is None:
|
|
240
240
|
return Result("defer", reason, revision)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
category = "mcps"
|
|
244
|
-
elif (
|
|
245
|
-
(kind == "mcp_tool" and source == "plugin")
|
|
246
|
-
or kind == "plugin_agent"
|
|
247
|
-
or (kind in {"skill", "command"} and ":" in name)
|
|
248
|
-
):
|
|
249
|
-
default = policy.plugins.default
|
|
250
|
-
category = "plugins"
|
|
251
|
-
elif kind in {"skill", "command"}:
|
|
252
|
-
default = policy.skills_default
|
|
253
|
-
category = "skills"
|
|
254
|
-
else:
|
|
255
|
-
return Result("defer", reason, revision)
|
|
256
|
-
return Result(
|
|
257
|
-
"deny" if default == "blocked" else "allow",
|
|
258
|
-
"policy_default",
|
|
259
|
-
revision,
|
|
260
|
-
f"admission.{category}.default",
|
|
261
|
-
default_description(category, default),
|
|
241
|
+
return decide(Subject(kind, name, source), policy, revision) or Result(
|
|
242
|
+
"defer", reason, revision
|
|
262
243
|
)
|
|
263
244
|
|
|
264
245
|
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class Subject:
|
|
248
|
+
"""What a hook or a gateway asks about: an action kind, a name and its source."""
|
|
249
|
+
|
|
250
|
+
kind: str
|
|
251
|
+
name: str
|
|
252
|
+
source: str
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def decide(subject: Subject, policy: Policy, revision: int | None = None) -> Result | None:
|
|
256
|
+
"""The category default `policy` sets for `subject`; None when no category covers it.
|
|
257
|
+
|
|
258
|
+
The one decision function ADR-0151 shares between the hook path, for a
|
|
259
|
+
subject no evidence resolved, and the gateway, which sees names only.
|
|
260
|
+
"""
|
|
261
|
+
kind, name, source = subject.kind, subject.name, subject.source
|
|
262
|
+
if kind == "mcp_tool" and source in {"user", "project", "local", "codex"}:
|
|
263
|
+
default = policy.mcps.default
|
|
264
|
+
category = "mcps"
|
|
265
|
+
elif (
|
|
266
|
+
(kind == "mcp_tool" and source == "plugin")
|
|
267
|
+
or kind == "plugin_agent"
|
|
268
|
+
or (kind in {"skill", "command"} and ":" in name)
|
|
269
|
+
):
|
|
270
|
+
default = policy.plugins.default
|
|
271
|
+
category = "plugins"
|
|
272
|
+
elif kind in {"skill", "command"}:
|
|
273
|
+
default = policy.skills_default
|
|
274
|
+
category = "skills"
|
|
275
|
+
else:
|
|
276
|
+
return None
|
|
277
|
+
return Result(
|
|
278
|
+
"deny" if default == "blocked" else "allow",
|
|
279
|
+
"policy_default",
|
|
280
|
+
revision,
|
|
281
|
+
f"admission.{category}.default",
|
|
282
|
+
default_description(category, default),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
265
286
|
def configuration_digest(scope: Scope, watch_paths: tuple[str, ...]) -> str:
|
|
266
287
|
"""Stat bounded named paths on the worker; never discover paths on a hook."""
|
|
267
288
|
root, project = Path(scope.config_root), Path(scope.project_root)
|
|
@@ -319,8 +340,26 @@ class PolicyRuntime:
|
|
|
319
340
|
self._paths: dict[Scope, tuple[str, ...]] = {}
|
|
320
341
|
self._error: str | None = None
|
|
321
342
|
self._last_call: float | None = None
|
|
343
|
+
#: When a Fleet fetch last succeeded, and for which enrollment. The
|
|
344
|
+
#: gateway treats a policy older than its time to live as no policy
|
|
345
|
+
#: (ADR-0151), and a fetch certifies only its own binding's policy.
|
|
346
|
+
self._refreshed: tuple[Binding, float] | None = None
|
|
322
347
|
self.worker = JobThread(Job("policy", 30, self.reconcile, wakeable=True))
|
|
323
348
|
|
|
349
|
+
@property
|
|
350
|
+
def refreshed_at(self) -> float | None:
|
|
351
|
+
"""When the policy the cache holds now was last fetched, or None.
|
|
352
|
+
|
|
353
|
+
None once the enrollment changes: the cache then holds another
|
|
354
|
+
binding's policy, which this fetch never certified.
|
|
355
|
+
"""
|
|
356
|
+
refreshed = self._refreshed
|
|
357
|
+
with self.cache.guard:
|
|
358
|
+
binding = self.cache.binding
|
|
359
|
+
if refreshed is None or refreshed[0] != binding:
|
|
360
|
+
return None
|
|
361
|
+
return refreshed[1]
|
|
362
|
+
|
|
324
363
|
def start(self) -> None:
|
|
325
364
|
self.worker.start()
|
|
326
365
|
self.worker.wake()
|
|
@@ -354,6 +393,7 @@ class PolicyRuntime:
|
|
|
354
393
|
self._fetch_at = self.clock()
|
|
355
394
|
try:
|
|
356
395
|
self.fetcher.refresh()
|
|
396
|
+
self._refreshed = (Binding.from_config(config), self.clock())
|
|
357
397
|
self._error = None
|
|
358
398
|
except PolicyInputError:
|
|
359
399
|
self._error = "policy_refresh_failed"
|
stacktrace/daemon/runtime.py
CHANGED
|
@@ -43,6 +43,8 @@ from .composition import (
|
|
|
43
43
|
from .config import DaemonConfig
|
|
44
44
|
from .costs import Baseline, CostPass, NearMissLog, OrgBaselines
|
|
45
45
|
from .fleet import DrainReport, FleetSubscriber
|
|
46
|
+
from .gateway import start_gateway
|
|
47
|
+
from .gateway_policy import GatewayPolicy, runtime_inputs
|
|
46
48
|
from .jobs import Backoff, Job, JobThread, Scheduler
|
|
47
49
|
from .lifecycle import StartOutcome, report_start
|
|
48
50
|
from .observer import SessionObserver, analyse_batch
|
|
@@ -617,6 +619,15 @@ def run_daemon(
|
|
|
617
619
|
fleet_thread.start()
|
|
618
620
|
usage_thread.start()
|
|
619
621
|
policy.start()
|
|
622
|
+
# Read from the environment only, as `guard serve` reads it.
|
|
623
|
+
gateway = start_gateway(
|
|
624
|
+
settings,
|
|
625
|
+
store,
|
|
626
|
+
server.publish,
|
|
627
|
+
token=os.environ.get("STACKTRACE_GUARD_TOKEN") or None,
|
|
628
|
+
on_commit=wake_delivery_jobs,
|
|
629
|
+
policy=GatewayPolicy(runtime_inputs(policy)),
|
|
630
|
+
)
|
|
620
631
|
try:
|
|
621
632
|
|
|
622
633
|
def ready() -> None:
|
|
@@ -625,6 +636,9 @@ def run_daemon(
|
|
|
625
636
|
|
|
626
637
|
service.run(on_ready=ready)
|
|
627
638
|
finally:
|
|
639
|
+
if gateway is not None:
|
|
640
|
+
gateway.shutdown()
|
|
641
|
+
gateway.server_close()
|
|
628
642
|
policy.close()
|
|
629
643
|
fleet_thread.close()
|
|
630
644
|
usage_thread.close()
|