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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.14.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
@@ -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")
@@ -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
@@ -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
- if kind == "mcp_tool" and source in {"user", "project", "local", "codex"}:
242
- default = policy.mcps.default
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"
@@ -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()