stacktrace-cli 0.6.0__py3-none-any.whl → 0.7.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 (37) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/cli.py +5 -0
  3. stacktrace_cli/correlate/acquire.py +60 -67
  4. stacktrace_cli/correlate/composition.py +4 -4
  5. stacktrace_cli/correlate/orchestrate.py +30 -38
  6. stacktrace_cli/daemon/identity.py +17 -1
  7. stacktrace_cli/daemon/presentation.py +6 -1
  8. stacktrace_cli/daemon/store.py +60 -15
  9. stacktrace_cli/daemon/webhook.py +36 -5
  10. stacktrace_cli/detector/blocked.py +12 -0
  11. stacktrace_cli/detector/deterministic.py +388 -182
  12. stacktrace_cli/detector/finding.py +3 -0
  13. stacktrace_cli/detector/render.py +5 -5
  14. stacktrace_cli/detector/rules.py +47 -8
  15. stacktrace_cli/detector/run.py +2 -2
  16. stacktrace_cli/monitor/inventory.py +495 -0
  17. stacktrace_cli/monitor/render.py +49 -1
  18. stacktrace_cli/monitor/server.py +40 -4
  19. stacktrace_cli/monitor/site/app.js +377 -81
  20. stacktrace_cli/monitor/site/index.html +27 -9
  21. stacktrace_cli/monitor/site/inventory.js +558 -0
  22. stacktrace_cli/monitor/site/styles.css +142 -29
  23. stacktrace_cli/monitor/state.py +45 -2
  24. stacktrace_cli/monitor/watch.py +156 -65
  25. stacktrace_cli/remote/cli.py +87 -0
  26. stacktrace_cli/remote/client.py +21 -4
  27. stacktrace_cli/remote/payload.py +10 -5
  28. stacktrace_cli/remote/policy_service.py +1497 -0
  29. stacktrace_cli/remote/policy_worker.py +64 -0
  30. stacktrace_cli/remote/redact.py +100 -2
  31. stacktrace_cli/remote/sync.py +11 -0
  32. stacktrace_cli/telemetry/events.py +12 -30
  33. stacktrace_cli/webhook/cli.py +35 -4
  34. {stacktrace_cli-0.6.0.dist-info → stacktrace_cli-0.7.0.dist-info}/METADATA +3 -3
  35. {stacktrace_cli-0.6.0.dist-info → stacktrace_cli-0.7.0.dist-info}/RECORD +37 -33
  36. {stacktrace_cli-0.6.0.dist-info → stacktrace_cli-0.7.0.dist-info}/WHEEL +0 -0
  37. {stacktrace_cli-0.6.0.dist-info → stacktrace_cli-0.7.0.dist-info}/entry_points.txt +0 -0
@@ -9,6 +9,7 @@ import threading
9
9
  from collections.abc import Callable
10
10
  from contextlib import suppress
11
11
  from dataclasses import dataclass
12
+ from datetime import UTC, datetime
12
13
  from pathlib import Path
13
14
 
14
15
  import httpx
@@ -18,7 +19,7 @@ from stacktrace_cli.detector.secrets import find_secrets
18
19
  from stacktrace_cli.telemetry import emit_derived
19
20
  from stacktrace_cli.webhook.config import DEFAULT_FORMAT, ConfigError, load_webhook_config
20
21
 
21
- from .store import FINDING, FindingStore, QueuedRecord
22
+ from .store import FindingStore, QueuedRecord
22
23
 
23
24
  WEBHOOK_SUBSCRIBER = "webhook"
24
25
 
@@ -88,7 +89,7 @@ class WebhookSubscriber:
88
89
  if not rows:
89
90
  break
90
91
  row = rows[0]
91
- if row.kind == FINDING and row.notifiable:
92
+ if row.deliverable:
92
93
  config, stopped = self._config()
93
94
  if config is None:
94
95
  return WebhookReport(delivered, skipped, stopped)
@@ -111,7 +112,7 @@ class WebhookSubscriber:
111
112
  else:
112
113
  skipped += 1
113
114
  self.store.advance_cursor(partition, row.seq, subscriber=WEBHOOK_SUBSCRIBER)
114
- if row.kind == FINDING and row.notifiable:
115
+ if row.deliverable:
115
116
  _report_delivered(partition, row)
116
117
  return WebhookReport(delivered=delivered, skipped=skipped)
117
118
 
@@ -246,6 +247,7 @@ def _payload(agent_kind: str, row: QueuedRecord) -> dict[str, object]:
246
247
  "version": 1,
247
248
  "type": "finding",
248
249
  "event_id": row.event_id,
250
+ "detected_at": row.created_at,
249
251
  "rule_id": rule_id,
250
252
  "severity": severity,
251
253
  "confidence": confidence,
@@ -380,6 +382,26 @@ def _severity_colour(severity: str) -> str:
380
382
  return _SEVERITY_COLOURS.get(severity.lower(), _DEFAULT_COLOUR)
381
383
 
382
384
 
385
+ def _slack_date(value: object) -> str | None:
386
+ """A viewer-local date token made only from a valid offset timestamp.
387
+
388
+ The fallback is regenerated from the parsed value rather than copying raw
389
+ input into Slack's control syntax. A malformed legacy row still delivers;
390
+ only its date label is omitted.
391
+ """
392
+ if not isinstance(value, str):
393
+ return None
394
+ try:
395
+ moment = datetime.fromisoformat(value)
396
+ if moment.tzinfo is None:
397
+ return None
398
+ epoch = int(moment.timestamp())
399
+ fallback = moment.astimezone(UTC).isoformat()
400
+ except (OSError, OverflowError, ValueError):
401
+ return None
402
+ return f"<!date^{epoch}^{{date_short_pretty}} at {{time_secs}}|{fallback}>"
403
+
404
+
383
405
  #: Dropped rather than escaped: mrkdwn has no escape these honour reliably,
384
406
  #: and no real component type or evidence kind contains one.
385
407
  #:
@@ -436,6 +458,10 @@ def _slack_payload(payload: dict[str, object]) -> dict[str, object]:
436
458
  assert isinstance(session, dict)
437
459
  title = _mrkdwn(payload.get("title") or payload["rule_id"])
438
460
  severity = str(payload["severity"])
461
+ detected = _slack_date(payload.get("detected_at"))
462
+ identity_parts = [f"finding ID {_mrkdwn(payload['event_id'])}"]
463
+ if detected is not None:
464
+ identity_parts.insert(0, f"Detected {detected}")
439
465
 
440
466
  fields: list[dict[str, str]] = []
441
467
  subject = payload.get("subject")
@@ -457,8 +483,9 @@ def _slack_payload(payload: dict[str, object]) -> dict[str, object]:
457
483
  if isinstance(kinds, list) and kinds:
458
484
  joined = ", ".join(_mrkdwn(kind) for kind in kinds)
459
485
  fields.append({"type": "mrkdwn", "text": f"*Evidence*\n{joined}"})
486
+ # Severity only (ADR-0088). Confidence is nearly always `high`, so in a
487
+ # channel it is a field that says nothing; the JSON format still carries it.
460
488
  fields.append({"type": "mrkdwn", "text": f"*Severity*\n{_mrkdwn(severity)}"})
461
- fields.append({"type": "mrkdwn", "text": f"*Confidence*\n{_mrkdwn(payload['confidence'])}"})
462
489
 
463
490
  blocks: list[dict[str, object]] = [
464
491
  {"type": "section", "text": {"type": "mrkdwn", "text": f"*{title}*"}},
@@ -472,7 +499,11 @@ def _slack_payload(payload: dict[str, object]) -> dict[str, object]:
472
499
  f"{_mrkdwn(session['agent_kind'])}"
473
500
  f" \u00b7 session {_mrkdwn(session['session_id'])}"
474
501
  ),
475
- }
502
+ },
503
+ {
504
+ "type": "mrkdwn",
505
+ "text": " \u00b7 ".join(identity_parts),
506
+ },
476
507
  ],
477
508
  },
478
509
  ]
@@ -35,6 +35,8 @@ from __future__ import annotations
35
35
  import re
36
36
  from dataclasses import dataclass
37
37
 
38
+ from stacktrace_cli.detector.rules import RELIABILITY_RULES
39
+
38
40
  #: Who can act on a block. Not a severity — the thing severity is derived from.
39
41
  #:
40
42
  #: `user` is the account owner or whoever drove the session; `upstream` is a
@@ -71,6 +73,10 @@ class Reason:
71
73
  """One way work stops, and what a reader should do about it."""
72
74
 
73
75
  code: str
76
+ #: The rule a finding for this reason is filed under -- one per reason, so
77
+ #: anything that reads only the rule id still tells a spend limit from a
78
+ #: throttle (ADR-0083). `code` stays the evidence `kind`.
79
+ rule_id: str
74
80
  pattern: re.Pattern[str]
75
81
  attribution: Attribution
76
82
  #: The finding's title. States the cause, never the symptom — a title
@@ -149,6 +155,8 @@ class Reason:
149
155
  for word in self.specific_vocabulary:
150
156
  if word != word.casefold() or not _SAFE_SPECIFIC.fullmatch(word):
151
157
  raise ValueError(f"{self.code}: {word!r} is not a lowercase, path-free specific")
158
+ if self.rule_id not in RELIABILITY_RULES:
159
+ raise ValueError(f"{self.code}: {self.rule_id!r} is not a reliability rule")
152
160
 
153
161
  def specific(self, text: str | None) -> str | None:
154
162
  """Which rule refused, where the client said so and we have a word for it.
@@ -190,6 +198,7 @@ class Reason:
190
198
  REASONS: tuple[Reason, ...] = (
191
199
  Reason(
192
200
  code="quota_exhausted",
201
+ rule_id="stacktrace-blocked-quota",
193
202
  label="Spend limit reached",
194
203
  pattern=re.compile(
195
204
  r"hit your (individual |team )?(spend|usage) limit"
@@ -227,6 +236,7 @@ REASONS: tuple[Reason, ...] = (
227
236
  ),
228
237
  Reason(
229
238
  code="policy_blocked",
239
+ rule_id="stacktrace-blocked-policy",
230
240
  label="Safety check refused",
231
241
  pattern=re.compile(
232
242
  r"could not evaluate this action and is blocking it for safety"
@@ -290,6 +300,7 @@ REASONS: tuple[Reason, ...] = (
290
300
  ),
291
301
  Reason(
292
302
  code="provider_throttled",
303
+ rule_id="stacktrace-blocked-throttled",
293
304
  label="Rate limited",
294
305
  # Anchored on what a client or a service *writes* when it throttles,
295
306
  # never on a bare status code. Measured on 76 real sessions, `429`
@@ -347,6 +358,7 @@ REASONS: tuple[Reason, ...] = (
347
358
  ),
348
359
  Reason(
349
360
  code="upstream_refused",
361
+ rule_id="stacktrace-blocked-upstream",
350
362
  label="Service refused",
351
363
  # Service-generic, not GitHub-shaped. The first two literals are how
352
364
  # GitHub words it and the rest are the status lines every HTTP service