stacktrace-cli 0.7.0__py3-none-any.whl → 0.8.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 (38) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/cli.py +60 -16
  3. stacktrace_cli/correlate/observed.py +47 -21
  4. stacktrace_cli/daemon/cli.py +25 -9
  5. stacktrace_cli/daemon/fleet.py +5 -2
  6. stacktrace_cli/daemon/jobs.py +6 -0
  7. stacktrace_cli/daemon/runtime.py +15 -3
  8. stacktrace_cli/daemon/store.py +12 -0
  9. stacktrace_cli/daemon/webhook.py +3 -13
  10. stacktrace_cli/detector/analyzer.py +1 -3
  11. stacktrace_cli/detector/deterministic.py +440 -82
  12. stacktrace_cli/detector/finding.py +0 -15
  13. stacktrace_cli/detector/prompts/jev-v2/stacktrace-infra-unsafe-action.json +10 -0
  14. stacktrace_cli/detector/reasoning.py +133 -16
  15. stacktrace_cli/detector/run.py +7 -5
  16. stacktrace_cli/detector/windows.py +150 -0
  17. stacktrace_cli/monitor/render.py +2 -2
  18. stacktrace_cli/monitor/server.py +7 -0
  19. stacktrace_cli/monitor/site/apple-icon.png +0 -0
  20. stacktrace_cli/monitor/site/favicon.ico +0 -0
  21. stacktrace_cli/monitor/site/icon.svg +7 -0
  22. stacktrace_cli/monitor/site/index.html +3 -0
  23. stacktrace_cli/monitor/site/styles.css +4 -303
  24. stacktrace_cli/remote/cli.py +97 -22
  25. stacktrace_cli/remote/client.py +0 -83
  26. stacktrace_cli/remote/config.py +5 -1
  27. stacktrace_cli/remote/policy_service.py +150 -10
  28. stacktrace_cli/remote/redact.py +0 -113
  29. stacktrace_cli/remote/sync.py +7 -1
  30. stacktrace_cli/telemetry/__init__.py +4 -3
  31. stacktrace_cli/telemetry/cli.py +13 -2
  32. stacktrace_cli/telemetry/events.py +256 -16
  33. stacktrace_cli/telemetry/posthog.py +11 -7
  34. stacktrace_cli/telemetry/state.py +32 -3
  35. {stacktrace_cli-0.7.0.dist-info → stacktrace_cli-0.8.0.dist-info}/METADATA +28 -13
  36. {stacktrace_cli-0.7.0.dist-info → stacktrace_cli-0.8.0.dist-info}/RECORD +38 -34
  37. {stacktrace_cli-0.7.0.dist-info → stacktrace_cli-0.8.0.dist-info}/WHEEL +0 -0
  38. {stacktrace_cli-0.7.0.dist-info → stacktrace_cli-0.8.0.dist-info}/entry_points.txt +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.7.0"
5
+ __version__ = "0.8.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
stacktrace_cli/cli.py CHANGED
@@ -38,12 +38,19 @@ from .options import analyzer_option, resolve_analyzer
38
38
  from .remote.cli import main as remote_cmd
39
39
  from .sessions.access import collect_sessions
40
40
  from .sessions.render import render_json, render_text
41
- from .telemetry import emit_error
41
+ from .telemetry import command_of, emit, emit_error
42
42
  from .telemetry.cli import telemetry as telemetry_cmd
43
+ from .telemetry.events import remember_internal
43
44
  from .webhook.cli import webhook as webhook_cmd
44
45
 
45
46
  PASSTHROUGH: Final = ("scan", "bom", "policy")
46
47
 
48
+ #: Commands whose runs are never counted as `command_run` (ADR-0090).
49
+ #: `telemetry` is where the answer is given and read back: reading the
50
+ #: preference or turning it off must not send an event or create an identity,
51
+ #: and `telemetry on` already says what it did with `installed`.
52
+ UNCOUNTED: Final = frozenset({"telemetry"})
53
+
47
54
  HOMEPAGE = "https://stacktrace.ai"
48
55
 
49
56
  #: The unit is required here and optional in `analysis.parse_since`. That is
@@ -129,13 +136,24 @@ class SectionedGroup(click.Group):
129
136
  """
130
137
 
131
138
  def invoke(self, ctx: click.Context) -> object:
132
- """Run the command; count an exception on the way out, and only an exception.
133
-
134
- Click's own control flow travels as exceptions too -- `Exit` from a
135
- pass-through's exit code, `Abort` on Ctrl-C, `UsageError` on a typo --
136
- and a bare one is not an error in this program. Everything else is,
137
- and only an allowlisted class name or `other` leaves (ADR-0039). A
138
- command that succeeds performs no telemetry work at all.
139
+ """Run the command; count a completed run, or an exception, on the way out.
140
+
141
+ A command that completes -- returns, or ends with `sys.exit(0)` the way
142
+ an OpenACA pass-through does -- hands off exactly one `command_run`
143
+ naming it from `telemetry.events.COMMANDS`, and nothing else
144
+ (ADR-0090). That hand-off is `emit`: no network I/O on this path, and
145
+ a failure of any kind is swallowed there. `UNCOUNTED` commands hand
146
+ off nothing.
147
+
148
+ Click's own control flow travels as exceptions too -- `Exit` from help
149
+ or a pass-through's exit code, `Abort` on Ctrl-C, `UsageError` on a
150
+ typo -- and a bare one is neither a completed run nor an error in this
151
+ program, so it sends nothing and creates no identity. That includes
152
+ `Exit(0)`: it is how `--help` ends, so a command that means "done"
153
+ returns rather than calling `ctx.exit()`. `--version` and the group's
154
+ own help end before this method runs. Everything else is an error,
155
+ and only an allowlisted class name or `other` leaves (ADR-0039); a
156
+ failed command is counted as its `error`, not as a run.
139
157
 
140
158
  A `ClickException` or an `Exit` a command site raised to wrap a real
141
159
  failure (`raise click.ClickException(...) from exc`, or
@@ -158,21 +176,35 @@ class SectionedGroup(click.Group):
158
176
  invalid arguments from the `error` metric. This package's own
159
177
  operational wrappers always raise a bare `click.ClickException`, never
160
178
  `UsageError`, to carry a real cause.
179
+
180
+ `UNCOUNTED` is checked on every path that would otherwise call
181
+ `emit_error`, not only `_count_run`'s: `telemetry off` wraps a failed
182
+ preference write in a caused `ClickException` the same way any other
183
+ operational wrapper does, and ADR-0090 requires that using `telemetry`
184
+ itself never creates an identity or sends an event, whether it
185
+ completes or fails (Codex review, PR #153).
161
186
  """
162
187
  try:
163
- return super().invoke(ctx)
188
+ result = super().invoke(ctx)
189
+ except SystemExit as exit_:
190
+ if exit_.code in (None, 0):
191
+ _count_run(ctx)
192
+ raise
164
193
  except click.exceptions.Abort:
165
194
  raise
166
195
  except click.UsageError:
167
196
  raise
168
197
  except (click.exceptions.Exit, click.ClickException) as error:
169
198
  cause = error.__cause__
170
- if cause is not None:
171
- emit_error(cause)
199
+ if cause is not None and ctx.invoked_subcommand not in UNCOUNTED:
200
+ emit_error(cause, "cli")
172
201
  raise
173
202
  except Exception as error:
174
- emit_error(error)
203
+ if ctx.invoked_subcommand not in UNCOUNTED:
204
+ emit_error(error, "cli")
175
205
  raise
206
+ _count_run(ctx)
207
+ return result
176
208
 
177
209
  def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
178
210
  commands: list[tuple[str, click.Command]] = []
@@ -201,6 +233,12 @@ class SectionedGroup(click.Group):
201
233
  formatter.write_dl(rows)
202
234
 
203
235
 
236
+ def _count_run(ctx: click.Context) -> None:
237
+ name = ctx.invoked_subcommand
238
+ if name not in UNCOUNTED:
239
+ emit("command_run", command=command_of(name))
240
+
241
+
204
242
  @click.group(cls=SectionedGroup)
205
243
  @click.option(
206
244
  "--version",
@@ -212,6 +250,9 @@ class SectionedGroup(click.Group):
212
250
  )
213
251
  def main() -> None:
214
252
  """Stacktrace: agent composition analysis, detection and upload."""
253
+ # Before any command, so `STACKTRACE_INTERNAL` set in a shell reaches the
254
+ # daemon that command may start, which never reads a shell (ADR-0091).
255
+ remember_internal()
215
256
 
216
257
 
217
258
  @click.command()
@@ -365,9 +406,10 @@ def detect(
365
406
  ) -> None:
366
407
  """Find security and reliability findings in what agents did.
367
408
 
368
- Three kinds of finding, under two families: a credential reaching an
369
- outbound call and success reported against evidence of failure are
370
- security; why the agent stopped, and who can unblock it, is reliability.
409
+ Four kinds of finding, under two families: a credential reaching an
410
+ outbound call, success reported against evidence of failure, and an
411
+ infrastructure change beyond what the user asked for are security; why
412
+ the agent stopped, and who can unblock it, is reliability.
371
413
  The two families carry their own severity ladders, because a blocked agent
372
414
  and a leaked credential cannot share one (ADR-0010).
373
415
 
@@ -378,7 +420,9 @@ def detect(
378
420
  "On a default run" is the whole of the qualification, and it is here rather
379
421
  than left to the flags: `--reasoning --analyzer jev` adds a third stage
380
422
  that sends per-call windows -- a completion claim, one verification command
381
- and its output -- to TypeSafe's Jev (ADR-0033). Both flags must be typed,
423
+ and its output; and, for a mutating infrastructure command, that command
424
+ with the user's own requests in the session -- to TypeSafe's Jev
425
+ (ADR-0033). Both flags must be typed,
382
426
  neither has a default, and this output names them when a session is left
383
427
  unjudged, so it is a run a reader can ask for and therefore one this
384
428
  paragraph has to account for.
@@ -1334,18 +1334,6 @@ def observed_across(*calls: ToolCallLike | None) -> tuple[str, ...]:
1334
1334
  return tuple(sorted(names))
1335
1335
 
1336
1336
 
1337
- def evidence_for(call: ToolCallLike, capability: str) -> dict[str, str] | None:
1338
- """What established this capability on this call, for a rule that cites it.
1339
-
1340
- A grade alone tells a reader how sure to be; this tells them why, which is
1341
- what makes the grade checkable rather than an assertion.
1342
- """
1343
- for observation in observe(call):
1344
- if observation.capability == capability:
1345
- return dict(observation.evidence)
1346
- return None
1347
-
1348
-
1349
1337
  def command_heads(command: object) -> tuple[str, ...]:
1350
1338
  """Every program a shell command runs, without its arguments.
1351
1339
 
@@ -1361,8 +1349,10 @@ def command_heads(command: object) -> tuple[str, ...]:
1361
1349
  #: The control operators a command line is split on, longest first so `&&` is
1362
1350
  #: never read as one `&`. `&` alone is deliberately absent, as it was from the
1363
1351
  #: expression this replaces: backgrounding is under-split rather than
1364
- #: mis-split, which costs a segment and never invents one.
1365
- _SEPARATORS = ("&&", "||", ";", "|", "\n")
1352
+ #: mis-split, which costs a segment and never invents one. `|&` is a pipe that
1353
+ #: carries stderr too, and is reported as `|`: read as `|` and then `&`, its
1354
+ #: receiver began with `&` and was dropped as punctuation.
1355
+ _SEPARATORS = ("&&", "||", "|&", ";", "|", "\n")
1366
1356
 
1367
1357
  #: Characters after which the shell is at the start of a word, so a `#` there
1368
1358
  #: opens a comment. Blanks and the newline are the ordinary case; the operator
@@ -1386,7 +1376,8 @@ def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
1386
1376
 
1387
1377
  Returns ``(segment_text, preceding_operator)`` pairs. The first segment's
1388
1378
  operator is ``None``; every later segment carries the ``&&``, ``||``, ``;``,
1389
- ``|`` or ``\\n`` that separated it from the one before it.
1379
+ ``|`` or ``\\n`` that separated it from the one before it (``|&`` is a
1380
+ pipe, and carried as ``|``).
1390
1381
 
1391
1382
  A control operator is an operator **only unquoted and unescaped** — that is
1392
1383
  the shell's own grammar, and it is verified against `bash` itself in
@@ -1474,6 +1465,15 @@ def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
1474
1465
  newline = command.find("\n", index)
1475
1466
  index = len(command) if newline == -1 else newline
1476
1467
  continue
1468
+ if command.startswith(">|", index):
1469
+ # The clobbering redirection is one operator: its `|` is not a pipe.
1470
+ # Read as one, `echo safe >| sqlite3 db 'DROP …'` -- which writes a
1471
+ # file named `sqlite3` -- fabricated a piped `sqlite3` (P2, PR #130
1472
+ # review).
1473
+ current.append(">|")
1474
+ index += 2
1475
+ at_word_start = True
1476
+ continue
1477
1477
  separator = next((s for s in _SEPARATORS if command.startswith(s, index)), None)
1478
1478
  if separator is None:
1479
1479
  current.append(char)
@@ -1481,15 +1481,16 @@ def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
1481
1481
  at_word_start = char in _WORD_START_BEFORE_COMMENT
1482
1482
  continue
1483
1483
  segment_text = "".join(current)
1484
+ operator = "|" if separator == "|&" else separator
1484
1485
  if segment_text.strip():
1485
1486
  parts.append((segment_text, preceding_op))
1486
- preceding_op = separator
1487
+ preceding_op = operator
1487
1488
  else:
1488
1489
  # Two separators with nothing between them (e.g. "&&\n"):
1489
1490
  # keep the conditional operator over a plain terminator so
1490
1491
  # the next real segment inherits the right dependency.
1491
1492
  if separator in ("&&", "||") or preceding_op is None:
1492
- preceding_op = separator
1493
+ preceding_op = operator
1493
1494
  current = []
1494
1495
  index += len(separator)
1495
1496
  at_word_start = True
@@ -1871,22 +1872,47 @@ def _strip_heredocs(command: str) -> str:
1871
1872
  """Drop heredoc bodies, keeping the lines that introduce them.
1872
1873
 
1873
1874
  A heredoc the shell never sees closed consumes the rest of the input, and
1874
- so does this: the loop below runs off the end and everything after the
1875
+ so does this: `_heredoc_lines` runs off the end and everything after the
1875
1876
  introducer is dropped. That is bash's own behaviour, not a fallback -- it
1876
1877
  reads to end-of-input looking for a terminator that is not there.
1877
1878
  """
1878
- kept: list[str] = []
1879
+ return "\n".join(line for line, _bodies in _heredoc_lines(command))
1880
+
1881
+
1882
+ def heredoc_bodies(command: str) -> tuple[tuple[str, tuple[str, ...]], ...]:
1883
+ """Each line of `command` that opens heredocs, with their bodies in the
1884
+ order bash reads them.
1885
+
1886
+ What `_strip_heredocs` drops, kept: a body is the text the shell feeds a
1887
+ program's stdin, which is what a caller asking what a program was *given*
1888
+ -- SQL fed to a database client -- needs and `command_segments` never
1889
+ returns. Arithmetic is masked first, as `_readable_commands` masks it, so a
1890
+ `<<` inside `$(( … ))` opens nothing here either.
1891
+ """
1892
+ return tuple(
1893
+ (line, tuple(bodies))
1894
+ for line, bodies in _heredoc_lines(_mask_arithmetic(command))
1895
+ if bodies
1896
+ )
1897
+
1898
+
1899
+ def _heredoc_lines(command: str) -> list[tuple[str, list[str]]]:
1900
+ """Each line that is not a heredoc body, with the bodies it opens."""
1901
+ kept: list[tuple[str, list[str]]] = []
1879
1902
  lines = command.splitlines()
1880
1903
  index = 0
1881
1904
  while index < len(lines):
1882
1905
  line = lines[index]
1883
- kept.append(line)
1906
+ bodies: list[str] = []
1907
+ kept.append((line, bodies))
1884
1908
  index += 1
1885
1909
  # In order, because that is the order bash reads the bodies in: a line
1886
1910
  # opening two heredocs has the first body directly after it and the
1887
1911
  # second after that one's terminator.
1888
1912
  for dash, terminator in _heredoc_introducers(line):
1913
+ start = index
1889
1914
  while index < len(lines) and not _closes_heredoc(lines[index], terminator, dash):
1890
1915
  index += 1
1916
+ bodies.append("\n".join(lines[start:index]))
1891
1917
  index += 1
1892
- return "\n".join(kept)
1918
+ return kept
@@ -14,6 +14,7 @@ from typing import TypeVar
14
14
 
15
15
  import click
16
16
 
17
+ from ..telemetry.events import ensure_internal_saved, follow_saved_internal_only
17
18
  from .config import DaemonConfig, parse_reasoning
18
19
  from .paths import RuntimePaths
19
20
  from .presentation import render_finding
@@ -145,6 +146,14 @@ def run_command(
145
146
  reasoning: bool,
146
147
  ) -> None:
147
148
  """Run the daemon in the foreground for a host supervisor."""
149
+ # This process outlives the shell that launched it, so it follows the
150
+ # saved mark only, and a later `STACKTRACE_INTERNAL=0` takes effect
151
+ # without a restart (ADR-0091). Save the launch value first, and say so
152
+ # in the daemon log if that fails.
153
+ warning = ensure_internal_saved()
154
+ if warning is not None:
155
+ click.echo(f"stacktrace: {warning}", err=True)
156
+ follow_saved_internal_only()
148
157
  _, _, run_daemon = _runtime()
149
158
  paths = RuntimePaths.from_environment()
150
159
  ctx = click.get_current_context()
@@ -210,14 +219,13 @@ def _reject_unobservable_kinds(config: DaemonConfig) -> None:
210
219
 
211
220
 
212
221
  @daemon.command()
213
- def status() -> None:
222
+ @click.option("--verbose", is_flag=True, help="Include detailed daemon diagnostics.")
223
+ def status(verbose: bool) -> None:
214
224
  """Report whether this endpoint is working, without reading a log.
215
225
 
216
- Every row answers a question someone asks when a machine looks quiet: a
217
- watermark hours old means the scanner has stopped; a queue that grows with
218
- a reader attached means the reader is not draining; a database that only
219
- grows means retention is not pruning; and a configuration without its source
220
- makes fleet-wide drift undebuggable (`docs/specs/daemon-scanning.md`).
226
+ The default output is a quick health check. Pass ``--verbose`` for the
227
+ diagnostic details used to investigate a quiet or misconfigured machine
228
+ (`docs/specs/daemon-scanning.md`).
221
229
  """
222
230
  from stacktrace_cli.build import version_label
223
231
 
@@ -242,7 +250,8 @@ def status() -> None:
242
250
  if live is not None:
243
251
  click.echo(f"version: {live.build}")
244
252
  click.echo(f"installed: {version_label()}")
245
- click.echo(f"started: {live.started_at}")
253
+ if verbose:
254
+ click.echo(f"started: {live.started_at}")
246
255
 
247
256
  try:
248
257
  # One `_store()` call apiece, all against the same file: if the first
@@ -251,9 +260,10 @@ def status() -> None:
251
260
  # them can print a partial line before that first failure.
252
261
  click.echo(f"watermark: {_watermark_line(paths)}")
253
262
  click.echo(f"queue depth: {_queue_line(paths)}")
254
- click.echo(f"cursors: {_cursor_line(paths)}")
255
263
  click.echo(f"parked: {_parked_line(paths)}")
256
- click.echo(f"database: {_database_line(paths)}")
264
+ if verbose:
265
+ click.echo(f"cursors: {_cursor_line(paths)}")
266
+ click.echo(f"database: {_database_line(paths)}")
257
267
  except (sqlite3.DatabaseError, ValueError) as exc:
258
268
  # A corrupt file or a schema newer than this build reads raises here
259
269
  # (`_check_version`'s own refusal) -- precisely the moment the daemon
@@ -263,6 +273,9 @@ def status() -> None:
263
273
  click.echo(f"database: unreadable — {exc}")
264
274
  click.echo(f"last error: {last_error(paths.log) or 'none reported'}")
265
275
 
276
+ if not verbose:
277
+ return
278
+
266
279
  try:
267
280
  config = _effective_config(live.config if live is not None else None)
268
281
  except ValueError as exc:
@@ -428,6 +441,9 @@ def start(reasoning: bool) -> None:
428
441
  os.environ.pop(_JEV_KEY_ENV, None)
429
442
  os.environ["STACKTRACE_REASONING"] = "false"
430
443
 
444
+ warning = ensure_internal_saved()
445
+ if warning is not None:
446
+ click.echo(f"stacktrace: {warning}", err=True)
431
447
  paths = RuntimePaths.from_environment()
432
448
  click.echo("Starting Stacktrace daemon...", err=True)
433
449
  try:
@@ -56,7 +56,7 @@ from stacktrace_cli.remote.upload_contract import (
56
56
  )
57
57
  from stacktrace_cli.telemetry import emit_derived
58
58
 
59
- from .store import FINDING, FindingStore, QueuedRecord
59
+ from .store import FINDING, FindingStore, QueuedRecord, native_session_id
60
60
 
61
61
  JsonObject = dict[str, Any]
62
62
 
@@ -622,7 +622,10 @@ class FleetSubscriber:
622
622
  "severity": str(payload.get("severity")),
623
623
  "sink": "fleet",
624
624
  "agent_kind": kind,
625
- "session_id": str((payload.get("session") or {}).get("session_id", "")), # type: ignore[union-attr]
625
+ "session_id": native_session_id(
626
+ kind,
627
+ str((payload.get("session") or {}).get("session_id", "")), # type: ignore[union-attr]
628
+ ),
626
629
  },
627
630
  )
628
631
 
@@ -184,6 +184,12 @@ class Job:
184
184
  # catch here for a value that should never reach a `Job` at
185
185
  # all.
186
186
  periods = max(1, math.floor((after - original_due_at) / self.interval) + 1)
187
+ # That holds for real numbers, not floats: `(due + 200) - due`
188
+ # can round to `199.99999999999997`, so `floor` comes out one
189
+ # short and the deadline lands exactly on `after`. It is
190
+ # never more than one short, so one extra period is enough.
191
+ if original_due_at + periods * self.interval <= after:
192
+ periods += 1
187
193
  self._due_at = original_due_at + periods * self.interval
188
194
  return True
189
195
 
@@ -595,8 +595,20 @@ def _log_webhook(report: WebhookReport) -> None:
595
595
  logger.info("webhook drain: %s", detail)
596
596
 
597
597
 
598
+ #: The component each job name reports its errors under (ADR-0091). A name
599
+ #: not here is `daemon`, which still says it was a scheduled job.
600
+ _JOB_COMPONENTS = {
601
+ "fleet": "fleet",
602
+ "flush": "fleet",
603
+ "webhook": "webhook",
604
+ "bom": "bom",
605
+ "sync-bom": "bom",
606
+ }
607
+
608
+
598
609
  def _log_job_error(name: str, error: Exception) -> None:
599
- emit_error(error)
610
+ # `fleet:<kind>` is the Fleet drain for one agent kind.
611
+ emit_error(error, _JOB_COMPONENTS.get(name.split(":", 1)[0], "daemon"))
600
612
  # The class name only, never `str(error)`: an exception's message can
601
613
  # carry a local path, transcript-derived text, or a server response body,
602
614
  # all of which the activity log's content-free contract forbids.
@@ -647,7 +659,7 @@ def _log_error(session: SessionKey, error: Exception) -> None:
647
659
  # Dated by the handler's own formatter, because `daemon status` reports
648
660
  # the last one and "there was an error" without "when" cannot distinguish
649
661
  # a repeating failure from one that happened once, weeks ago.
650
- emit_error(error)
662
+ emit_error(error, "observer")
651
663
  # `%r`, not `%s`: `session_id` is peer-supplied, and an embedded newline
652
664
  # under `%s` would let it forge its own fake log line.
653
665
  logger.warning(
@@ -684,7 +696,7 @@ def _log_composition_error(work: tuple[Scope, ...], error: Exception) -> None:
684
696
  """Every call here is a failure (unlike `_log_drain`/`_log_bom`, which
685
697
  also log a quiet success), so the marker is unconditional — a build that
686
698
  keeps failing must be visible to `daemon status`'s marker-only read."""
687
- emit_error(error)
699
+ emit_error(error, "composition")
688
700
  # Never the path itself: `project` is a local filesystem path, the same
689
701
  # kind of content the activity log's contract forbids for an exception's
690
702
  # message. "project"/"user" keeps the shape (which scopes failed, and
@@ -117,6 +117,18 @@ class Notification:
117
117
  body: str
118
118
 
119
119
 
120
+ def native_session_id(agent_kind: str, qualified: str) -> str:
121
+ """The stored finding's `session_id` is `f"{agent_kind}:{native_id}"`
122
+ (`FindingStore._validate_session`). ADR-0067's payload allowlist is agent
123
+ kind and *native* session ID as separate fields, and telemetry's own
124
+ `session_id` domain is a bare UUID (`telemetry/events.py`), so the
125
+ qualifier must come off before either one sees it. Every subscriber that
126
+ reports a delivery reads it through here: Fleet's did not, and sent the
127
+ qualified form, which telemetry refused as `telemetry_failed` (ADR-0091)."""
128
+ prefix = f"{agent_kind}:"
129
+ return qualified.removeprefix(prefix) if qualified.startswith(prefix) else qualified
130
+
131
+
120
132
  class FindingStore:
121
133
  """Persist findings and at-least-once delivery state per exact session."""
122
134
 
@@ -19,7 +19,7 @@ from stacktrace_cli.detector.secrets import find_secrets
19
19
  from stacktrace_cli.telemetry import emit_derived
20
20
  from stacktrace_cli.webhook.config import DEFAULT_FORMAT, ConfigError, load_webhook_config
21
21
 
22
- from .store import FindingStore, QueuedRecord
22
+ from .store import FindingStore, QueuedRecord, native_session_id
23
23
 
24
24
  WEBHOOK_SUBSCRIBER = "webhook"
25
25
 
@@ -223,16 +223,6 @@ def _stop_reason(error: Exception) -> str:
223
223
  return f"delivery failed: {detail}"
224
224
 
225
225
 
226
- def _native_session_id(agent_kind: str, qualified: str) -> str:
227
- """The stored finding's `session_id` is `f"{agent_kind}:{native_id}"`
228
- (`FindingStore._validate_session`). ADR-0067's payload allowlist is agent
229
- kind and *native* session ID as separate fields, and telemetry's own
230
- `session_id` domain is a bare UUID (`telemetry/events.py`), so the
231
- qualifier must come off before either one sees it."""
232
- prefix = f"{agent_kind}:"
233
- return qualified.removeprefix(prefix) if qualified.startswith(prefix) else qualified
234
-
235
-
236
226
  def _payload(agent_kind: str, row: QueuedRecord) -> dict[str, object]:
237
227
  document = row.payload
238
228
  session = document.get("session")
@@ -253,7 +243,7 @@ def _payload(agent_kind: str, row: QueuedRecord) -> dict[str, object]:
253
243
  "confidence": confidence,
254
244
  "session": {
255
245
  "agent_kind": agent_kind,
256
- "session_id": _native_session_id(agent_kind, session_id),
246
+ "session_id": native_session_id(agent_kind, session_id),
257
247
  },
258
248
  }
259
249
  # Additive (ADR-0073), and deliberately not required: a row stored before
@@ -534,6 +524,6 @@ def _report_delivered(agent_kind: str, row: QueuedRecord) -> None:
534
524
  "severity": str(payload.get("severity")),
535
525
  "sink": "webhook",
536
526
  "agent_kind": agent_kind,
537
- "session_id": _native_session_id(agent_kind, str(session_id)),
527
+ "session_id": native_session_id(agent_kind, str(session_id)),
538
528
  },
539
529
  )
@@ -39,7 +39,7 @@ import os
39
39
  import shutil
40
40
  import subprocess
41
41
  from dataclasses import dataclass, field
42
- from typing import Literal, Protocol, runtime_checkable
42
+ from typing import Protocol, runtime_checkable
43
43
 
44
44
  from stacktrace_cli.detector.finding import UnknownReason
45
45
 
@@ -203,5 +203,3 @@ class ClaudeCodeAnalyzer:
203
203
  #: A kind absent here yields `analyzer_unavailable`, never a fallback to another
204
204
  #: vendor's CLI.
205
205
  ANALYZERS: dict[str, Analyzer] = {"claude-code": ClaudeCodeAnalyzer()}
206
-
207
- Stage3Reason = Literal["refusal", "timeout", "malformed_verdict", "analyzer_error"]