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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.14.0"
5
+ __version__ = "0.16.0"
6
6
 
7
7
  # Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
8
8
  # logged in any other process would reach Python's last-resort handler and
@@ -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
 
@@ -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: `cursor` and `codex` have
207
- composition coverage but OpenAIDR does not read their sessions yet, and a
208
- daemon quietly finding nothing for one of them is the failure this refusal
209
- exists to make loud. What can be observed is OpenAIDR's answer (ADR-0107).
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)
@@ -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")
@@ -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
- return None, None, None, None
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