stacktrace-cli 0.13.0__py3-none-any.whl → 0.14.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.13.0"
5
+ __version__ = "0.14.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`).
@@ -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
@@ -602,6 +621,12 @@ class FleetSubscriber:
602
621
  except RemoteClientError as error:
603
622
  raise DrainStopped(f"upload rejected: {error}") from error
604
623
  else:
624
+ # Where Fleet's console is, for the link a webhook alert carries
625
+ # (ADR-0147). Fleet names it on every upload; the origin is all
626
+ # that is kept.
627
+ origin = _console_origin(getattr(result, "dashboard_url", None))
628
+ if origin is not None:
629
+ self.store.set_fleet_console(origin)
605
630
  sent = len(payload["detections"])
606
631
  kept = int(getattr(result, "detections_ingested", sent))
607
632
  if kept < sent:
@@ -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
  {
@@ -35,10 +35,20 @@ class Scope:
35
35
  config_root: str
36
36
  project_root: str
37
37
  global_config: str = ""
38
+ agent_kind: str = "claude-code"
38
39
 
39
40
  def __post_init__(self) -> None:
41
+ if self.agent_kind not in {"claude-code", "codex"}:
42
+ raise ValueError("unsupported_agent")
40
43
  if not self.global_config:
41
- object.__setattr__(self, "global_config", str(Path(self.config_root) / ".claude.json"))
44
+ object.__setattr__(
45
+ self,
46
+ "global_config",
47
+ str(
48
+ Path(self.config_root)
49
+ / ("config.toml" if self.agent_kind == "codex" else ".claude.json")
50
+ ),
51
+ )
42
52
  for value in (self.config_root, self.project_root, self.global_config):
43
53
  if not value or len(value) > 4096 or "\x00" in value or not Path(value).is_absolute():
44
54
  raise ValueError("invalid_scope")
@@ -86,6 +96,7 @@ class EvidenceIds:
86
96
  inventory_digest: str
87
97
  vulnerability_run_id: str | None
88
98
  posture_run_id: str | None
99
+ inventory_gap_count: int = 0
89
100
 
90
101
 
91
102
  @dataclass(frozen=True)
@@ -227,7 +238,7 @@ class DecisionCache:
227
238
  """Use defaults from the same policy as the active decision table."""
228
239
  if policy is None:
229
240
  return Result("defer", reason, revision)
230
- if kind == "mcp_tool" and source in {"user", "project", "local"}:
241
+ if kind == "mcp_tool" and source in {"user", "project", "local", "codex"}:
231
242
  default = policy.mcps.default
232
243
  category = "mcps"
233
244
  elif (
@@ -263,6 +274,15 @@ def configuration_digest(scope: Scope, watch_paths: tuple[str, ...]) -> str:
263
274
  str(root / "plugins" / "installed_plugins.json"),
264
275
  *watch_paths,
265
276
  }
277
+ if scope.agent_kind == "codex":
278
+ paths = {
279
+ str(root),
280
+ str(root / "config.toml"),
281
+ str(root / "hooks.json"),
282
+ str(project / ".codex"),
283
+ str(project / ".codex/config.toml"),
284
+ *watch_paths,
285
+ }
266
286
  records = []
267
287
  for name in sorted(paths):
268
288
  try:
@@ -408,12 +428,15 @@ class PolicyRuntime:
408
428
  try:
409
429
  if set(value) != {"agent_kind", "config_root", "global_config", "project_root"}:
410
430
  return False
411
- if value["agent_kind"] != "claude-code" or not all(
431
+ if value["agent_kind"] not in {"claude-code", "codex"} or not all(
412
432
  isinstance(item, str) for item in value.values()
413
433
  ):
414
434
  return False
415
435
  scope = Scope(
416
- str(value["config_root"]), str(value["project_root"]), str(value["global_config"])
436
+ str(value["config_root"]),
437
+ str(value["project_root"]),
438
+ str(value["global_config"]),
439
+ str(value["agent_kind"]),
417
440
  )
418
441
  with _asset_transition_lock(self.config_path, timeout=0.05):
419
442
  config = load_remote_config(self.config_path)
@@ -445,7 +468,7 @@ class PolicyRuntime:
445
468
  for key, value in action.items()
446
469
  ):
447
470
  return Result("defer", "invalid_action")
448
- if action["agent_kind"] != "claude-code":
471
+ if action["agent_kind"] not in {"claude-code", "codex"}:
449
472
  return Result("defer", "unsupported_agent")
450
473
  kind, name, source = action["action_kind"], action["name"], action["source"]
451
474
  if kind not in {"mcp_tool", "skill", "command", "plugin_agent"}:
@@ -454,6 +477,7 @@ class PolicyRuntime:
454
477
  str(action["config_root"]),
455
478
  str(action["project_root"]),
456
479
  str(action["global_config"]),
480
+ str(action["agent_kind"]),
457
481
  )
458
482
  with _asset_transition_lock(self.config_path, timeout=0.05):
459
483
  config = load_remote_config(self.config_path)
@@ -527,6 +551,7 @@ class PolicyRuntime:
527
551
  {
528
552
  "scope_id": hashlib.sha256(repr(scope).encode()).hexdigest()[:16],
529
553
  "project_root": scope.project_root,
554
+ "agent_kind": scope.agent_kind,
530
555
  "evaluated_revision": None if snapshot is None else snapshot.revision,
531
556
  "update_pending": snapshot is None
532
557
  or snapshot.token != self.cache.capture(scope)
@@ -541,6 +566,9 @@ class PolicyRuntime:
541
566
  if snapshot is None or snapshot.evidence is None
542
567
  else snapshot.evidence.posture_run_id,
543
568
  "evidence_age_seconds": age,
569
+ "inventory_gap_count": None
570
+ if snapshot is None or snapshot.evidence is None
571
+ else snapshot.evidence.inventory_gap_count,
544
572
  "valid": inputs_current and snapshot is not None,
545
573
  }
546
574
  )
@@ -56,6 +56,14 @@ _GUIDANCE = {
56
56
  "Code or artifacts were published without explicit user request",
57
57
  "Verify the publication was intended and inspect what was made public.",
58
58
  ),
59
+ "stacktrace-credential-switched": (
60
+ "The agent got past a refusal with a credential nobody gave it",
61
+ (
62
+ "Revoke or rotate that credential. Then remove it from where the agent can "
63
+ "read it. If the agent needs this access, grant it to the agent's own credential."
64
+ ),
65
+ ),
66
+ # Retired by ADR-0149. Findings stored before then still name them.
59
67
  "stacktrace-mcp-transient-failure": (
60
68
  "MCP auth failure followed by recovery on a server",
61
69
  "Investigate whether the recovery involved a credential change or a transient issue.",
@@ -23,6 +23,7 @@ from stacktrace.cost.clock import environment_zone
23
23
  from stacktrace.detector.run import NO_ANALYZER
24
24
  from stacktrace.private_state import ensure_private_directory
25
25
  from stacktrace.remote.config import get_config_path as remote_config_path
26
+ from stacktrace.remote.identity import resolve_asset_identity
26
27
  from stacktrace.sessions.access import IncrementalSessions, collect_sessions
27
28
  from stacktrace.telemetry import emit_error
28
29
  from stacktrace.telemetry.posthog import flush as telemetry_flush
@@ -79,6 +80,17 @@ INITIAL_COMPOSITION_TIMEOUT = 5.0
79
80
  logger = logging.getLogger(__name__)
80
81
 
81
82
 
83
+ def _environment_name() -> str | None:
84
+ """This machine as Fleet names it: the `display_name` registration sends
85
+ (ADR-0105), so a Slack footer reads the same as Fleet's alert. A machine
86
+ that registered reads its stored id and writes nothing. None if it cannot
87
+ be read: the footer then leaves the Environment out."""
88
+ try:
89
+ return resolve_asset_identity().display_name or None
90
+ except Exception: # noqa: BLE001 - a footer label never stops the daemon
91
+ return None
92
+
93
+
82
94
  def daemon_is_running(path: Path) -> bool:
83
95
  """Whether a daemon this user owns is listening at `path`.
84
96
 
@@ -267,7 +279,14 @@ def run_daemon(
267
279
  # every reader below: Devin's answer is a call to its server
268
280
  # (ADR-0112).
269
281
  signed_in = SignedInAccounts(store, org_id=FleetOrg())
270
- webhook_subscriber = WebhookSubscriber(store=store, config_path=get_config_path())
282
+ # The same reader the Fleet upload asks, so a Slack message names
283
+ # the person exactly when a Fleet detection does (ADR-0147).
284
+ webhook_subscriber = WebhookSubscriber(
285
+ store=store,
286
+ config_path=get_config_path(),
287
+ person=signed_in.person,
288
+ environment=_environment_name(),
289
+ )
271
290
  server = NotificationServer(paths.socket, store)
272
291
  started_at = datetime.now(UTC).isoformat()
273
292
  server.on_status = lambda: {
@@ -97,6 +97,10 @@ DEFAULT_PRUNE_INTERVAL = timedelta(hours=6)
97
97
  #: that read every transcript.
98
98
  _BASELINE_COMPLETE = "cost_baseline_complete"
99
99
 
100
+ #: The `schema_meta` key holding the console address Fleet named in its last
101
+ #: upload response, so a webhook alert can link to the finding (ADR-0147).
102
+ _FLEET_CONSOLE = "fleet_console"
103
+
100
104
  #: Usage rows Fleet accepted are kept this long at minute grain, and rows
101
105
  #: never sent are rolled into their hour after it (usage-upload.md, Retention).
102
106
  USAGE_MINUTE_RETENTION = timedelta(days=7)
@@ -733,6 +737,35 @@ class FindingStore:
733
737
  (_BASELINE_COMPLETE, "true" if complete else "false"),
734
738
  )
735
739
 
740
+ def set_fleet_console(self, origin: str) -> None:
741
+ """Keep the console address Fleet named in an upload response. Each
742
+ response replaces it, so switching Fleets follows the next upload."""
743
+ with self._lock:
744
+ self._connection.execute(
745
+ "INSERT OR REPLACE INTO schema_meta (key, value) VALUES (?, ?)",
746
+ (_FLEET_CONSOLE, origin),
747
+ )
748
+
749
+ def fleet_console(self) -> str | None:
750
+ """The console address the last upload response named, or None
751
+ before any upload."""
752
+ with self._lock:
753
+ row = self._connection.execute(
754
+ "SELECT value FROM schema_meta WHERE key = ?", (_FLEET_CONSOLE,)
755
+ ).fetchone()
756
+ return row[0] if row else None
757
+
758
+ def clear_fleet_console(self) -> None:
759
+ """Forget the cached console address.
760
+
761
+ Called alongside `clear_cursors()` when Fleet is unconfigured, and
762
+ when the drain registers under a new asset id (a tenant switch): the
763
+ cached origin names the console of the tenant it was uploaded to,
764
+ and a webhook alert must never link a finding to a console that can
765
+ no longer resolve it."""
766
+ with self._lock:
767
+ self._connection.execute("DELETE FROM schema_meta WHERE key = ?", (_FLEET_CONSOLE,))
768
+
736
769
  def baseline_complete(self) -> bool:
737
770
  """Whether the stored baseline rows came from a complete backfill.
738
771
  False until one has: a store whose backfill never completed has no
@@ -2467,9 +2500,21 @@ class FindingStore:
2467
2500
  #: the gate (cost-notifications §2.2).
2468
2501
  _ANY_SEVERITY = frozenset({"stacktrace-cost-runaway-session"})
2469
2502
 
2503
+ #: Rules that notify at `medium` confidence when their severity is `high`.
2504
+ #: Credential egress: a credential that left is as serious however its
2505
+ #: egress was seen, and `curl`, `gh` or `ssh` is read from the command
2506
+ #: string, so it is graded `medium` (ADR-0148).
2507
+ _MEDIUM_CONFIDENCE_WHEN_HIGH = frozenset({"stacktrace-credential-egress"})
2508
+
2470
2509
  @staticmethod
2471
2510
  def _eligible(finding: dict[str, object]) -> bool:
2472
- return finding.get("confidence") == "high" and (
2511
+ confidence = finding.get("confidence")
2512
+ sure = confidence == "high" or (
2513
+ confidence == "medium"
2514
+ and finding.get("severity") == "high"
2515
+ and finding.get("rule_id") in FindingStore._MEDIUM_CONFIDENCE_WHEN_HIGH
2516
+ )
2517
+ return sure and (
2473
2518
  finding.get("severity") in FindingStore._NOTIFIABLE_SEVERITIES
2474
2519
  or finding.get("rule_id") in FindingStore._ANY_SEVERITY
2475
2520
  )