stacktrace-cli 0.3.0__py3-none-any.whl → 0.4.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 (67) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/analysis.py +202 -20
  3. stacktrace_cli/build.py +130 -0
  4. stacktrace_cli/cli.py +137 -55
  5. stacktrace_cli/correlate/acquire.py +23 -2
  6. stacktrace_cli/correlate/orchestrate.py +73 -9
  7. stacktrace_cli/correlate/render.py +7 -5
  8. stacktrace_cli/daemon/__init__.py +1 -0
  9. stacktrace_cli/daemon/cli.py +106 -0
  10. stacktrace_cli/daemon/client.py +134 -0
  11. stacktrace_cli/daemon/composition.py +133 -0
  12. stacktrace_cli/daemon/observer.py +173 -0
  13. stacktrace_cli/daemon/paths.py +35 -0
  14. stacktrace_cli/daemon/protocol.py +29 -0
  15. stacktrace_cli/daemon/runtime.py +222 -0
  16. stacktrace_cli/daemon/server.py +164 -0
  17. stacktrace_cli/daemon/service.py +154 -0
  18. stacktrace_cli/daemon/store.py +368 -0
  19. stacktrace_cli/detector/blocked.py +450 -0
  20. stacktrace_cli/detector/cache.py +245 -9
  21. stacktrace_cli/detector/deterministic.py +359 -511
  22. stacktrace_cli/detector/finding.py +163 -15
  23. stacktrace_cli/detector/history.py +210 -0
  24. stacktrace_cli/detector/jev.py +213 -0
  25. stacktrace_cli/detector/priors.py +53 -285
  26. stacktrace_cli/detector/prompts/__init__.py +20 -0
  27. stacktrace_cli/detector/prompts/jev-v1/stacktrace-deceptive-completion.json +16 -0
  28. stacktrace_cli/detector/prompts/jev-v2/stacktrace-deceptive-completion.json +16 -0
  29. stacktrace_cli/detector/reasoning.py +858 -308
  30. stacktrace_cli/detector/render.py +88 -7
  31. stacktrace_cli/detector/rules.py +69 -56
  32. stacktrace_cli/detector/run.py +423 -77
  33. stacktrace_cli/detector/secrets.py +8 -6
  34. stacktrace_cli/detector/windows.py +293 -0
  35. stacktrace_cli/monitor/reasoning.py +153 -21
  36. stacktrace_cli/monitor/render.py +421 -10
  37. stacktrace_cli/monitor/server.py +242 -21
  38. stacktrace_cli/monitor/site/app.js +1154 -178
  39. stacktrace_cli/monitor/site/index.html +16 -1
  40. stacktrace_cli/monitor/site/styles.css +89 -1
  41. stacktrace_cli/monitor/state.py +27 -1
  42. stacktrace_cli/monitor/watch.py +227 -47
  43. stacktrace_cli/options.py +107 -0
  44. stacktrace_cli/private_state.py +38 -0
  45. stacktrace_cli/remote/cli.py +20 -10
  46. stacktrace_cli/remote/detect_payload.py +37 -5
  47. stacktrace_cli/remote/redact.py +3 -3
  48. stacktrace_cli/remote/spool.py +3 -2
  49. stacktrace_cli/remote/sync_detect.py +15 -1
  50. stacktrace_cli/remote/upload_contract.py +7 -0
  51. stacktrace_cli/sessions/access.py +24 -0
  52. stacktrace_cli/sessions/index.py +30 -0
  53. stacktrace_cli/sessions/protocols.py +13 -0
  54. stacktrace_cli/sessions/render.py +15 -9
  55. stacktrace_cli/telemetry/__init__.py +11 -0
  56. stacktrace_cli/telemetry/cli.py +67 -0
  57. stacktrace_cli/telemetry/events.py +266 -0
  58. stacktrace_cli/telemetry/posthog.py +126 -0
  59. stacktrace_cli/telemetry/state.py +190 -0
  60. {stacktrace_cli-0.3.0.dist-info → stacktrace_cli-0.4.0.dist-info}/METADATA +56 -24
  61. stacktrace_cli-0.4.0.dist-info/RECORD +90 -0
  62. {stacktrace_cli-0.3.0.dist-info → stacktrace_cli-0.4.0.dist-info}/WHEEL +1 -1
  63. stacktrace_cli/detector/markers.py +0 -158
  64. stacktrace_cli/detector/prompts/v1/stacktrace-injected-instruction-followed.md +0 -14
  65. stacktrace_cli/detector/prompts/v1/stacktrace-intent-drift.md +0 -11
  66. stacktrace_cli-0.3.0.dist-info/RECORD +0 -67
  67. {stacktrace_cli-0.3.0.dist-info → stacktrace_cli-0.4.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/cli.py CHANGED
@@ -24,16 +24,22 @@ from typing import Final
24
24
  import click
25
25
  from openaca.cli import main as openaca_cli
26
26
 
27
- from . import __version__
28
27
  from .analysis import analyse, parse_since
28
+ from .build import version_label
29
+ from .daemon.cli import daemon as daemon_cmd
30
+ from .daemon.cli import findings
29
31
  from .detector.cache import VerdictCache, default_directory
32
+ from .detector.history import DriftHistory, default_history_path
30
33
  from .detector.render import render_json as render_detections_json
31
34
  from .detector.render import render_text as render_detections_text
32
35
  from .detector.run import DEFAULT_BUDGET, DEFAULT_SAMPLE_BUDGET
33
36
  from .monitor.server import serve
37
+ from .options import analyzer_option, resolve_analyzer
34
38
  from .remote.cli import main as remote_cmd
35
39
  from .sessions.access import collect_sessions
36
40
  from .sessions.render import render_json, render_text
41
+ from .telemetry import emit_error
42
+ from .telemetry.cli import telemetry as telemetry_cmd
37
43
 
38
44
  PASSTHROUGH: Final = ("scan", "bom", "policy")
39
45
 
@@ -87,7 +93,9 @@ def _show_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
87
93
  # click's own `version_option` callback opens with the same two guards.
88
94
  if not value or ctx.resilient_parsing:
89
95
  return
90
- click.echo(f"stacktrace {__version__} (openaca {version('openaca')})")
96
+ # A build from a branch or PR reports its commit as a PEP 440 local label,
97
+ # `0.4.0+86eef86`; a release install prints the bare version.
98
+ click.echo(f"stacktrace {version_label()} (openaca {version('openaca')})")
91
99
  ctx.exit()
92
100
 
93
101
 
@@ -101,6 +109,52 @@ class SectionedGroup(click.Group):
101
109
  right section with no edit to this class.
102
110
  """
103
111
 
112
+ def invoke(self, ctx: click.Context) -> object:
113
+ """Run the command; count an exception on the way out, and only an exception.
114
+
115
+ Click's own control flow travels as exceptions too -- `Exit` from a
116
+ pass-through's exit code, `Abort` on Ctrl-C, `UsageError` on a typo --
117
+ and a bare one is not an error in this program. Everything else is,
118
+ and only an allowlisted class name or `other` leaves (ADR-0039). A
119
+ command that succeeds performs no telemetry work at all.
120
+
121
+ A `ClickException` or an `Exit` a command site raised to wrap a real
122
+ failure (`raise click.ClickException(...) from exc`, or
123
+ `raise click.exceptions.Exit(exc.exit_code) from exc` where a specific
124
+ process exit code matters more than Click's own formatting -- both
125
+ patterns are already in this package) still carries that failure as
126
+ `__cause__`, and it is the cause's class that is counted -- exempting
127
+ either outright would make the `error` metric miss every operational
128
+ failure a command turned into one, whichever wrapper it used. A bare
129
+ `ClickException` or `Exit` Click or a command raised on its own, with
130
+ no wrapped cause, is what a typo, an expected precondition or a
131
+ pass-through's own exit code looks like here, and stays uncounted.
132
+ `Abort` is exempt outright, cause or not: Ctrl-C is the user's act,
133
+ never an operational failure to attribute to a cause.
134
+
135
+ `UsageError` (and `BadParameter`, its subclass every parameter
136
+ callback in this package raises) is exempt outright, cause or not:
137
+ `_day_count` chains `from error` onto its `BadParameter` for a clean
138
+ traceback, not to flag an operational failure, and ADR-0039 excludes
139
+ invalid arguments from the `error` metric. This package's own
140
+ operational wrappers always raise a bare `click.ClickException`, never
141
+ `UsageError`, to carry a real cause.
142
+ """
143
+ try:
144
+ return super().invoke(ctx)
145
+ except click.exceptions.Abort:
146
+ raise
147
+ except click.UsageError:
148
+ raise
149
+ except (click.exceptions.Exit, click.ClickException) as error:
150
+ cause = error.__cause__
151
+ if cause is not None:
152
+ emit_error(cause)
153
+ raise
154
+ except Exception as error:
155
+ emit_error(error)
156
+ raise
157
+
104
158
  def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
105
159
  commands: list[tuple[str, click.Command]] = []
106
160
  for name in self.list_commands(ctx):
@@ -135,7 +189,7 @@ class SectionedGroup(click.Group):
135
189
  expose_value=False,
136
190
  is_eager=True,
137
191
  callback=_show_version,
138
- help="Show the stacktrace and OpenACA versions and exit.",
192
+ help="Show the stacktrace and OpenACA versions (and build commit) and exit.",
139
193
  )
140
194
  def main() -> None:
141
195
  """Stacktrace: agent composition analysis, detection and upload."""
@@ -223,36 +277,31 @@ def sessions(
223
277
  help="Show every detection and all of its evidence. Default: the summary alone.",
224
278
  )
225
279
  @click.option(
226
- "--reasoning",
227
- is_flag=True,
280
+ "--reasoning/--no-reasoning",
228
281
  default=False,
229
- help="Analyse flagged sessions with a reasoning model, run through the "
230
- "agent's own CLI. Off by default: it is the only stage that sends anything "
231
- "session-derived off this machine, and it spends the developer's own "
232
- "provider quota, so a run that does neither is the one to reach for first. "
233
- "--reasoning turns it on "
234
- "and --budget caps a noisy day. Leaving it off does not make the run "
235
- "offline: correlation runs first either way and matches advisories by "
236
- "sending the package coordinates of the components a session invoked to "
237
- "osv.dev. Sessions that qualified are still counted, and named in "
238
- "--format json, so the run says what it did not do.",
282
+ hidden=True,
283
+ help="Analyse flagged sessions with a reasoning model. Off by default: it "
284
+ "is the only stage that sends anything session-derived off this machine, "
285
+ "so a run that does not is the one to reach for first. --reasoning turns "
286
+ "it on, --analyzer names who answers, and --budget caps a noisy day.",
239
287
  )
240
288
  @click.option(
241
289
  "--budget",
242
- type=click.IntRange(min=1),
290
+ type=click.IntRange(min=0),
243
291
  default=DEFAULT_BUDGET,
244
- show_default=True,
245
- help="How many sessions one run may send for inference.",
292
+ hidden=True,
293
+ help="How many sessions one run may send for inference. 0 is every session.",
246
294
  )
247
295
  @click.option(
248
296
  "--sample-budget",
249
297
  type=click.IntRange(min=0),
250
298
  default=DEFAULT_SAMPLE_BUDGET,
251
- show_default=True,
299
+ hidden=True,
252
300
  help="Quiet sessions to analyse per run with no evidence behind them. Off "
253
301
  "by default: it is the only route that spends inference with no evidence, "
254
302
  "so it is asked for rather than assumed. Shares the --budget cap.",
255
303
  )
304
+ @analyzer_option
256
305
  @click.option(
257
306
  "--cache/--no-cache",
258
307
  default=True,
@@ -285,37 +334,38 @@ def detect(
285
334
  reasoning: bool,
286
335
  budget: int,
287
336
  sample_budget: int,
337
+ analyzer: str,
288
338
  cache: bool,
289
339
  project_map: tuple[str, ...],
290
340
  root: Path | None,
291
341
  ) -> None:
292
342
  """Find security and reliability findings in what agents did.
293
343
 
294
- Four kinds of finding, under two families: a credential reaching an
295
- outbound call and an injected instruction followed are security; a
296
- vulnerable component actually reached is security with a vulnerability
297
- behind it; a stalled loop and a hung call are reliability. The two
298
- families carry their own severity ladders, because a stalled loop and a
299
- leaked credential cannot share one (ADR-0010).
300
-
301
- Two stages need no model or credential, and they are the two that run by
302
- default. The third sends flagged sessions to the agent's own CLI:
303
- --reasoning turns it on, capped by --budget. Sessions that qualified for it
304
- are counted either way, and named in --format json, so a run without it
305
- says so rather than reading as a clean one.
306
-
307
- That third stage is the one that sends session content off this machine:
308
- the agent's own CLI hands the prompts, arguments and results a rule needs
309
- to the provider it is already authenticated against (ADR-0004 -- provider
310
- affinity, so a transcript goes back to the vendor that produced it, and
311
- there is no fallback to any other). Without --reasoning the run is the two
312
- stages that send nothing.
313
-
314
- One network call happens before any of them, with or without --reasoning:
344
+ Three kinds of finding, under two families: a credential reaching an
345
+ outbound call and success reported against evidence of failure are
346
+ security; why the agent stopped, and who can unblock it, is reliability.
347
+ The two families carry their own severity ladders, because a blocked agent
348
+ and a leaked credential cannot share one (ADR-0010).
349
+
350
+ Both stages read the record on this machine. Neither needs a model, a
351
+ credential or an account, and on a default run nothing derived from a
352
+ session -- no prompt, no argument, no result -- is sent anywhere.
353
+
354
+ "On a default run" is the whole of the qualification, and it is here rather
355
+ than left to the flags: `--reasoning --analyzer jev` adds a third stage
356
+ that sends per-call windows -- a completion claim, one verification command
357
+ and its output -- to TypeSafe's Jev (ADR-0033). Both flags must be typed,
358
+ neither has a default, and this output names them when a session is left
359
+ unjudged, so it is a run a reader can ask for and therefore one this
360
+ paragraph has to account for.
361
+
362
+ One network call is made on every run, and it is not the detector's:
315
363
  correlation matches advisories by sending the package coordinates of the
316
- components a session invoked to osv.dev. Coordinates only -- never a
317
- prompt, an argument or a result.
364
+ components a session invoked to osv.dev. Coordinates only, so this is not
365
+ an offline run, and without stage three it is the only thing that leaves
366
+ this machine.
318
367
  """
368
+ analyzer = resolve_analyzer(analyzer, reasoning)
319
369
  try:
320
370
  # One call, because the order it runs in is `analysis.py`'s to know:
321
371
  # `monitor` and `sync detect` read the same pipeline and must not be
@@ -329,6 +379,7 @@ def detect(
329
379
  reasoning=reasoning,
330
380
  budget=budget,
331
381
  sample_budget=sample_budget,
382
+ analyzer=analyzer,
332
383
  # Not gated on `reasoning`. A verdict already paid for is an
333
384
  # answer this run has, and `reasoning` governs whether new ones may
334
385
  # be commissioned, not whether old ones may be read -- the rule
@@ -336,9 +387,17 @@ def detect(
336
387
  # session read as graded or ungraded depending on a flag that
337
388
  # spent nothing either way.
338
389
  cache=VerdictCache(default_directory()) if cache else None,
390
+ # One point per session per reasoning run, so the monitor's drift
391
+ # line grows with each run made here (ADR-0033).
392
+ history=DriftHistory(default_history_path()),
339
393
  )
340
394
  except ValueError as error:
341
- raise click.ClickException(str(error)) from error
395
+ # No `from error`: this is argument validation (a bad --project-map,
396
+ # an unrecognised analyzer), which ADR-0039 excludes from the `error`
397
+ # metric the same way UsageError already is. Chaining it would let
398
+ # the dispatcher count it by cause and, on a fresh machine, create an
399
+ # installation identity for an invocation that never ran anything.
400
+ raise click.ClickException(str(error))
342
401
  result = analysis.run
343
402
  output = (
344
403
  render_detections_json(result)
@@ -369,20 +428,30 @@ def detect(
369
428
  "entirely when nothing on disk has changed.",
370
429
  )
371
430
  @click.option(
372
- "--reasoning",
373
- is_flag=True,
431
+ "--reasoning/--no-reasoning",
374
432
  default=False,
375
- help="Let the page analyse one session with a reasoning model, run through "
376
- "the agent's own CLI. Off by default: stage 3 spends your provider quota, and a page "
377
- "left open is the wrong place for that to be implicit. Capped by --budget.",
433
+ hidden=True,
434
+ help="Let the page judge sessions with a reasoning model. Off by default: "
435
+ "stage 3 is the only one that sends anything off this machine, and a page "
436
+ "left open is the wrong place for that to be implicit. --analyzer names "
437
+ "who answers; --budget caps a pass.",
378
438
  )
379
439
  @click.option(
380
440
  "--budget",
381
- type=click.IntRange(min=1),
382
- default=DEFAULT_BUDGET,
383
- show_default=True,
384
- help="Reasoning runs this monitor may spend in total, if --reasoning is on.",
441
+ type=click.IntRange(min=0),
442
+ default=0,
443
+ hidden=True,
444
+ help=(
445
+ "Sessions the first pass may send for inference, if --reasoning is on. "
446
+ "The default 0 is every session: a page that answered some of them and "
447
+ "left the rest silently unjudged would be a partial account presented "
448
+ "as a whole one. Only the first pass commissions anything — later ticks "
449
+ "read the cache and the drift history — so this is what the page costs "
450
+ "in total, not what it costs every few seconds. After that, a session "
451
+ "is judged on request, and this is that request's budget too."
452
+ ),
385
453
  )
454
+ @analyzer_option
386
455
  @click.option(
387
456
  "--no-open", is_flag=True, default=False, help="Print the URL, do not open a browser."
388
457
  )
@@ -424,6 +493,7 @@ def monitor(
424
493
  interval: float,
425
494
  reasoning: bool,
426
495
  budget: int,
496
+ analyzer: str,
427
497
  no_open: bool,
428
498
  agent_kinds: tuple[str, ...],
429
499
  since: str,
@@ -440,9 +510,9 @@ def monitor(
440
510
 
441
511
  Loopback only, and free to leave open: a pass is skipped when nothing has
442
512
  changed, advisory lookups are asked once per component, and the two stages
443
- that need no model are the only ones that run. --reasoning adds a button
444
- that spends your provider quota, off unless asked for.
513
+ that need no model are the only ones that run.
445
514
  """
515
+ analyzer = resolve_analyzer(analyzer, reasoning)
446
516
  try:
447
517
  code = serve(
448
518
  host=host,
@@ -450,6 +520,7 @@ def monitor(
450
520
  interval=interval,
451
521
  reasoning=reasoning,
452
522
  budget=budget,
523
+ analyzer=analyzer,
453
524
  open_browser=not no_open,
454
525
  echo=click.echo,
455
526
  agent_kinds=agent_kinds,
@@ -458,7 +529,15 @@ def monitor(
458
529
  project_map=project_map,
459
530
  root=root,
460
531
  )
461
- except (OSError, ValueError) as error:
532
+ except ValueError as error:
533
+ # No `from error`: a non-loopback --host is argument validation,
534
+ # excluded from the `error` metric the same way UsageError already is
535
+ # (see the matching comment in `detect`). Unlike the OSError below --
536
+ # a port already in use, a real runtime condition -- the host itself
537
+ # is not something running the command again with the same argument
538
+ # would ever make succeed.
539
+ raise click.ClickException(str(error))
540
+ except OSError as error:
462
541
  raise click.ClickException(str(error)) from error
463
542
  if code:
464
543
  raise click.exceptions.Exit(code)
@@ -477,3 +556,6 @@ main.add_command(remote_cmd, "remote")
477
556
  main.add_command(sessions)
478
557
  main.add_command(detect)
479
558
  main.add_command(monitor)
559
+ main.add_command(daemon_cmd, "daemon")
560
+ main.add_command(findings)
561
+ main.add_command(telemetry_cmd, "telemetry")
@@ -397,6 +397,28 @@ def _identity_of(component: dict[str, Any]) -> str | None:
397
397
  return None
398
398
 
399
399
 
400
+ def identity_versions(
401
+ documents: Sequence[dict[str, Any]], identities: AbstractSet[str]
402
+ ) -> dict[str, str | None]:
403
+ """Each wanted identity's own declared version, the first time it is found.
404
+
405
+ The identity this codebase assigns does not encode a version — that is
406
+ what lets one scan answer for a component across every composition it
407
+ appears in — so a cache keyed on identity alone cannot tell an upgrade
408
+ from no change at all. Pairing identity with this lets a long-lived
409
+ lookup cache do that.
410
+ """
411
+ versions: dict[str, str | None] = {}
412
+ for document in documents:
413
+ for component in document.get("components") or []:
414
+ identity = _identity_of(component)
415
+ if identity is None or identity not in identities or identity in versions:
416
+ continue
417
+ version = component.get("version")
418
+ versions[identity] = version if isinstance(version, str) else None
419
+ return versions
420
+
421
+
400
422
  def _identity_by_ref(document: dict[str, Any]) -> dict[str, str]:
401
423
  return {
402
424
  str(component["bom-ref"]): identity
@@ -419,8 +441,7 @@ def _scan(
419
441
  **A response whose shape is not the one we read is `None`, never `{}`.** The
420
442
  two are different answers: `{}` says the scan ran and matched nothing, and
421
443
  `_attach_advisories` records `advisories_checked=True` on the strength of
422
- it, which suppresses both the vulnerability findings and the
423
- `advisories_unchecked` warning that would have said we never looked. Every
444
+ it, which is what a reader of the composition takes for *we looked*. Every
424
445
  field this function reads is therefore checked against the shape
425
446
  `Advisory` declares — `findings` a list, `fixed_in`, `source`, `severity`
426
447
  and `title` a string or absent — so version skew or a corrupted response
@@ -40,7 +40,13 @@ from dataclasses import dataclass, replace
40
40
  from pathlib import Path
41
41
  from typing import Any
42
42
 
43
- from stacktrace_cli.correlate.acquire import Built, advisories_for, build_bom, load_bom
43
+ from stacktrace_cli.correlate.acquire import (
44
+ Built,
45
+ advisories_for,
46
+ build_bom,
47
+ identity_versions,
48
+ load_bom,
49
+ )
44
50
  from stacktrace_cli.correlate.composition import Advisory, Composition
45
51
  from stacktrace_cli.correlate.join import is_builtin, is_builtin_subagent, join_key
46
52
  from stacktrace_cli.correlate.project_map import ProjectMapping, remap
@@ -94,7 +100,7 @@ def _build(kind: str, project: Path | None) -> Built | None:
94
100
  _SCAN_WORKERS = 8
95
101
 
96
102
 
97
- def _build_all(
103
+ def build_compositions(
98
104
  work: Sequence[tuple[str, Path | None]],
99
105
  build: Callable[[str, Path | None], Built | None] = _build,
100
106
  ) -> dict[tuple[str, str | None], Built]:
@@ -132,6 +138,9 @@ def _key(project: Path | None) -> str | None:
132
138
  return str(project) if project is not None else None
133
139
 
134
140
 
141
+ BuildAll = Callable[[Sequence[tuple[str, Path | None]]], dict[tuple[str, str | None], Built]]
142
+
143
+
135
144
  def _invoked_identities(correlated: object) -> set[str]:
136
145
  """Every component identity a session actually reached.
137
146
 
@@ -140,9 +149,10 @@ def _invoked_identities(correlated: object) -> set[str]:
140
149
  rather than unasked. Advisories for both is the honest answer.
141
150
 
142
151
  A rejected call never reached the component it resolved to — the same fact
143
- `priors.py`'s `_advisory_reach` already acts on for findings. Without the same exclusion here, a denied call's coordinates would
144
- still be sent to osv.dev: an unnecessary disclosure and scan cost for a
145
- component nothing actually ran.
152
+ `sessions/outcome.py` states for every rule that claims a call reached
153
+ something. Without the same exclusion here, a denied call's coordinates
154
+ would still be sent to osv.dev: an unnecessary disclosure and scan cost
155
+ for a component nothing actually ran.
146
156
  """
147
157
  identities: set[str] = set()
148
158
  for session in correlated.sessions: # type: ignore[attr-defined]
@@ -167,6 +177,54 @@ AdvisoryLookup = Callable[
167
177
  AttachAdvisories = Callable[[CorrelatedView, "Sequence[Built]"], CorrelatedView]
168
178
 
169
179
 
180
+ class CachingAdvisoryLookup:
181
+ """`advisories_for`, asked once per identity for the life of the process.
182
+
183
+ `None` from the underlying lookup means *not checked at all*, which must
184
+ stay distinguishable from *checked and found nothing* — an unchecked
185
+ composition must never read as a clean one. So a `None` answer is not
186
+ cached: the next tick asks again rather than remembering an absence of
187
+ knowledge as knowledge.
188
+
189
+ Shared by `monitor`'s watcher and the daemon's session observer: both are
190
+ long-lived processes asking the same question repeatedly, and a second
191
+ copy of this memo would be a second chance to get the `None`-means-unasked
192
+ distinction wrong.
193
+ """
194
+
195
+ def __init__(self, lookup: Callable[..., Any] = advisories_for) -> None:
196
+ self._lookup = lookup
197
+ self._known: dict[tuple[str, str | None], tuple[Any, ...]] = {}
198
+ self._asked: set[tuple[str, str | None]] = set()
199
+
200
+ def __call__(
201
+ self, documents: Sequence[dict[str, Any]], identities: AbstractSet[str]
202
+ ) -> dict[str, tuple[Any, ...]] | None:
203
+ wanted = set(identities or ())
204
+ # Keyed by (identity, version) rather than identity alone: the identity
205
+ # this codebase assigns does not encode a version, so an upgrade or
206
+ # downgrade of an already-asked component must still be asked about.
207
+ versions = identity_versions(documents, wanted)
208
+ keys = {identity: (identity, versions.get(identity)) for identity in wanted}
209
+ unasked = {identity for identity in wanted if keys[identity] not in self._asked}
210
+ if unasked:
211
+ matched = self._lookup(documents, unasked)
212
+ if matched is None:
213
+ # Never checked. Do not record these as asked, and do not
214
+ # answer for the ones we do know: a partial answer here would
215
+ # be indistinguishable from a complete one downstream.
216
+ return None
217
+ self._asked |= {keys[identity] for identity in unasked}
218
+ self._known.update(
219
+ {keys[identity]: advisories for identity, advisories in matched.items()}
220
+ )
221
+ return {
222
+ identity: self._known[keys[identity]]
223
+ for identity in wanted
224
+ if keys[identity] in self._known
225
+ }
226
+
227
+
170
228
  def advisory_attacher(lookup: AdvisoryLookup = advisories_for) -> AttachAdvisories:
171
229
  """`_attach_advisories`, with the network call substitutable.
172
230
 
@@ -307,7 +365,7 @@ def _mapped_compositions(
307
365
  mappings: tuple[ProjectMapping, ...],
308
366
  build_all: Callable[
309
367
  [Sequence[tuple[str, Path | None]]], dict[tuple[str, str | None], Built]
310
- ] = _build_all,
368
+ ] = build_compositions,
311
369
  ) -> dict[tuple[str, str], Built]:
312
370
  """Compositions for projects the user has said moved, built where they say.
313
371
 
@@ -399,8 +457,14 @@ def acquire_correlated_view(
399
457
  The four hooks exist for tests and for nothing else; production passes none
400
458
  of them and gets the module-level implementations.
401
459
  """
402
- build_all = build_all or _build_all
403
- mapped = mapped or _mapped_compositions
460
+ build_all = build_all or build_compositions
461
+ if mapped is None:
462
+ mapped = lambda correlated, supplied, mappings: _mapped_compositions(
463
+ correlated,
464
+ supplied,
465
+ mappings,
466
+ build_all=build_all,
467
+ )
404
468
  projects_worth_scanning = projects_worth_scanning or _projects_worth_scanning
405
469
  attach_advisories = attach_advisories or _attach_advisories
406
470
 
@@ -423,7 +487,7 @@ def acquire_correlated_view(
423
487
  for kind in sorted({s.agent_kind for s in sessions.sessions if s.agent_kind})
424
488
  if kind not in supplied_kinds
425
489
  ]
426
- # Guarded, because `_build_all` opens a thread pool: asking it for nothing
490
+ # Guarded, because `build_compositions` opens a thread pool: asking it for nothing
427
491
  # is a pool created and torn down to return an empty dict.
428
492
  for (kind, _), built in (build_all(user_scope) if user_scope else {}).items():
429
493
  builts.append(built)
@@ -22,6 +22,7 @@ from stacktrace_cli.correlate.composition import Advisory, Component, Compositio
22
22
  from stacktrace_cli.correlate.join import Resolution
23
23
  from stacktrace_cli.correlate.observed import CAPABILITY_NAMES, observe
24
24
  from stacktrace_cli.correlate.record import CorrelatedSession, CorrelatedView
25
+ from stacktrace_cli.sessions.index import calls_by_span
25
26
  from stacktrace_cli.sessions.outcome import denied
26
27
  from stacktrace_cli.sessions.protocols import ToolCallLike
27
28
  from stacktrace_cli.sessions.render import session_document
@@ -47,7 +48,9 @@ def render_text(view: CorrelatedView, include_content: bool = False, detail: boo
47
48
 
48
49
 
49
50
  def _calls_by_span(correlated: CorrelatedSession) -> dict[str, ToolCallLike]:
50
- return {call.span: call for turn in correlated.session.turns for call in turn.tool_calls}
51
+ # One definition, in `sessions`, because the detector needs the same walk to
52
+ # date a finding and must not import a renderer to get it.
53
+ return calls_by_span(correlated.session)
51
54
 
52
55
 
53
56
  #: How an observation was established, in the order a reader should weigh it.
@@ -421,8 +424,7 @@ def _session_lines(correlated: CorrelatedSession, include_content: bool) -> list
421
424
 
422
425
  if include_content:
423
426
  # The durable request and the standing context — the only place a
424
- # context-item evidence span (`stacktrace-injection-marker`'s
425
- # context-item route) can be looked up. Both wait for
427
+ # context-item evidence span can be looked up. Both wait for
426
428
  # `--include-content` exactly as a turn's text and a call's arguments
427
429
  # do; the JSON path already carries them via `session_document()`.
428
430
  if session.initial_prompt:
@@ -589,8 +591,8 @@ def _composition_document(composition: Composition) -> dict[str, object]:
589
591
  "component_count": len(composition.components),
590
592
  # Advisory matching, both halves. Without `advisories_checked` an empty
591
593
  # map reads identically whether nothing was found or nothing was looked
592
- # for, and `stacktrace-advisory-reach` — the whole vulnerability
593
- # category — had no input at this seam at all.
594
+ # for, and a reader of the composition cannot tell a clean scan from a
595
+ # scan that never ran.
594
596
  "advisories_checked": composition.advisories_checked,
595
597
  "advisories": {
596
598
  bom_ref: [_advisory_document(advisory) for advisory in advisories]
@@ -0,0 +1 @@
1
+ """Host-level session observation and finding delivery."""
@@ -0,0 +1,106 @@
1
+ """CLI surfaces for daemon lifecycle, subscriptions, and local findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+
11
+ from .paths import RuntimePaths
12
+ from .store import FindingStore, SessionKey
13
+
14
+
15
+ @click.group()
16
+ def daemon() -> None:
17
+ """Manage automatic session detection."""
18
+
19
+
20
+ @daemon.command("run", hidden=True)
21
+ def run_command() -> None:
22
+ _, _, run_daemon = _runtime()
23
+ run_daemon(RuntimePaths.from_environment())
24
+
25
+
26
+ @daemon.command()
27
+ def status() -> None:
28
+ """Show whether the local Stacktrace daemon is running."""
29
+ daemon_is_running, _, _ = _runtime()
30
+ paths = RuntimePaths.from_environment()
31
+ if not daemon_is_running(paths.socket):
32
+ raise click.ClickException("Stacktrace daemon is not running")
33
+ click.echo("Stacktrace daemon is running.")
34
+
35
+
36
+ @daemon.command()
37
+ @click.option(
38
+ "--agent-kind",
39
+ required=True,
40
+ help="Agent kind that owns the native session.",
41
+ )
42
+ @click.option(
43
+ "--session-id",
44
+ envvar="CLAUDE_CODE_SESSION_ID",
45
+ required=True,
46
+ help="Native agent session ID. Defaults to CLAUDE_CODE_SESSION_ID.",
47
+ )
48
+ def subscribe(agent_kind: str, session_id: str) -> None:
49
+ """Relay notifications for exactly one native agent session."""
50
+ try:
51
+ _, follow_notifications, _ = _runtime()
52
+ follow_notifications(
53
+ RuntimePaths.from_environment(),
54
+ SessionKey(agent_kind, session_id),
55
+ stdout=sys.stdout,
56
+ stderr=sys.stderr,
57
+ )
58
+ except RuntimeError as error:
59
+ raise click.ClickException(str(error)) from error
60
+
61
+
62
+ def _runtime():
63
+ if os.name != "posix":
64
+ raise click.ClickException(
65
+ "automatic session monitoring is currently available on macOS and Linux"
66
+ )
67
+ from .runtime import daemon_is_running, follow_notifications, run_daemon
68
+
69
+ return daemon_is_running, follow_notifications, run_daemon
70
+
71
+
72
+ @click.command()
73
+ @click.option(
74
+ "--agent-kind",
75
+ required=True,
76
+ help="Agent kind that owns the native session.",
77
+ )
78
+ @click.option(
79
+ "--session",
80
+ "session_id",
81
+ envvar="CLAUDE_CODE_SESSION_ID",
82
+ required=True,
83
+ help="Native session ID. Defaults to CLAUDE_CODE_SESSION_ID.",
84
+ )
85
+ @click.option(
86
+ "--format",
87
+ "output_format",
88
+ type=click.Choice(["text", "json"]),
89
+ default="text",
90
+ show_default=True,
91
+ )
92
+ def findings(agent_kind: str, session_id: str, output_format: str) -> None:
93
+ """Show Stacktrace findings for one native agent session."""
94
+ paths = RuntimePaths.from_environment()
95
+ stored = FindingStore(paths.state).findings(SessionKey(agent_kind, session_id))
96
+ if output_format == "json":
97
+ click.echo(json.dumps({"findings": stored}, indent=2))
98
+ return
99
+ if not stored:
100
+ click.echo("No Stacktrace findings for this session.")
101
+ return
102
+ for finding in stored:
103
+ click.echo(
104
+ f"{str(finding.get('severity', 'unknown')).upper()} "
105
+ f"{finding.get('rule_id', 'unknown-rule')}: {finding.get('title', 'Finding')}"
106
+ )