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.
Files changed (39) hide show
  1. stacktrace/__init__.py +1 -1
  2. stacktrace/cost/measures.py +2 -2
  3. stacktrace/cost/rules.py +1 -1
  4. stacktrace/daemon/config.py +26 -0
  5. stacktrace/daemon/costs.py +4 -4
  6. stacktrace/daemon/fleet.py +29 -1
  7. stacktrace/daemon/gateway.py +153 -0
  8. stacktrace/daemon/gateway_policy.py +161 -0
  9. stacktrace/daemon/policy_collect.py +45 -5
  10. stacktrace/daemon/policy_runtime.py +93 -25
  11. stacktrace/daemon/presentation.py +8 -0
  12. stacktrace/daemon/runtime.py +34 -1
  13. stacktrace/daemon/store.py +46 -1
  14. stacktrace/daemon/webhook.py +231 -27
  15. stacktrace/detector/deterministic.py +550 -525
  16. stacktrace/detector/rules.py +14 -10
  17. stacktrace/finding_contract.py +355 -38
  18. stacktrace/finding_titles.py +9 -20
  19. stacktrace/gateway/analyzer.py +117 -11
  20. stacktrace/gateway/check.py +4 -0
  21. stacktrace/gateway/principal.py +88 -0
  22. stacktrace/gateway/service.py +29 -4
  23. stacktrace/gateway/sink.py +129 -15
  24. stacktrace/hooks/claude.py +13 -5
  25. stacktrace/hooks/codex.py +84 -0
  26. stacktrace/monitor/render.py +1 -3
  27. stacktrace/monitor/site/app.js +29 -2
  28. stacktrace/monitor/site/styles.css +1 -0
  29. stacktrace/monitor/verdicts.py +26 -9
  30. stacktrace/remote/detect_payload.py +34 -2
  31. stacktrace/remote/hook_policy.py +52 -7
  32. stacktrace/remote/policy_service.py +0 -21
  33. stacktrace/remote/policy_show.py +11 -1
  34. stacktrace/remote/redact.py +5 -1
  35. stacktrace/remote/upload_contract.py +70 -1
  36. {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.15.0.dist-info}/METADATA +4 -4
  37. {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.15.0.dist-info}/RECORD +39 -35
  38. {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.15.0.dist-info}/WHEEL +0 -0
  39. {stacktrace_cli-0.13.0.dist-info → stacktrace_cli-0.15.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.13.0"
5
+ __version__ = "0.15.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
@@ -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 is
131
- `tool-retry-storm`'s. A call in `skip` was already cited by another rule.
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
- #: (`tool-retry-storm`, `blocked-*`): one event is never reported twice.
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`).
@@ -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")
@@ -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: `tool-retry-storm` and
464
- #: every `blocked-*` rule, which together are the reliability family. One
465
- #: event, one report (cost-phase-1 §1).
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 `tool-retry-storm` and the `blocked-*` rules cite, which the
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."""
@@ -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
- 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
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
- return callable(getattr(openaca.core, "collect_policy_evidence", None))
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(request["config_root"], request["project_root"], request["global_config"])
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 Path(scope.global_config) == (root / ".claude.json").resolve():
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
- evidence: Any = collector(policy, target=target, project=Path(scope.project_root))
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
  {