stacktrace-cli 0.7.1__py3-none-any.whl → 0.9.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 (61) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/analysis.py +13 -1
  3. stacktrace_cli/cli.py +63 -17
  4. stacktrace_cli/correlate/observed.py +47 -21
  5. stacktrace_cli/cost/__init__.py +6 -0
  6. stacktrace_cli/cost/baseline.py +493 -0
  7. stacktrace_cli/cost/clock.py +40 -0
  8. stacktrace_cli/cost/components.py +143 -0
  9. stacktrace_cli/cost/evidence.py +69 -0
  10. stacktrace_cli/cost/ledger.py +58 -0
  11. stacktrace_cli/cost/measures.py +351 -0
  12. stacktrace_cli/cost/models.py +38 -0
  13. stacktrace_cli/cost/rules.py +1138 -0
  14. stacktrace_cli/cost/runs.py +126 -0
  15. stacktrace_cli/cost/tokens.py +69 -0
  16. stacktrace_cli/cost/totals.py +62 -0
  17. stacktrace_cli/daemon/cli.py +58 -0
  18. stacktrace_cli/daemon/config.py +42 -0
  19. stacktrace_cli/daemon/costs.py +882 -0
  20. stacktrace_cli/daemon/db.py +65 -0
  21. stacktrace_cli/daemon/fleet.py +8 -3
  22. stacktrace_cli/daemon/jobs.py +96 -0
  23. stacktrace_cli/daemon/observer.py +17 -2
  24. stacktrace_cli/daemon/presentation.py +33 -0
  25. stacktrace_cli/daemon/runtime.py +122 -11
  26. stacktrace_cli/daemon/scan.py +31 -0
  27. stacktrace_cli/daemon/store.py +255 -2
  28. stacktrace_cli/daemon/webhook.py +3 -13
  29. stacktrace_cli/detector/analyzer.py +1 -3
  30. stacktrace_cli/detector/deterministic.py +440 -82
  31. stacktrace_cli/detector/finding.py +1 -16
  32. stacktrace_cli/detector/prompts/jev-v2/stacktrace-infra-unsafe-action.json +10 -0
  33. stacktrace_cli/detector/reasoning.py +133 -16
  34. stacktrace_cli/detector/render.py +14 -12
  35. stacktrace_cli/detector/rules.py +58 -4
  36. stacktrace_cli/detector/run.py +7 -5
  37. stacktrace_cli/detector/windows.py +150 -0
  38. stacktrace_cli/monitor/costs.py +375 -0
  39. stacktrace_cli/monitor/offline.py +266 -0
  40. stacktrace_cli/monitor/render.py +81 -23
  41. stacktrace_cli/monitor/server.py +5 -0
  42. stacktrace_cli/monitor/site/app.js +64 -1
  43. stacktrace_cli/monitor/site/cost.js +436 -0
  44. stacktrace_cli/monitor/site/index.html +8 -0
  45. stacktrace_cli/monitor/site/inventory.js +1 -1
  46. stacktrace_cli/monitor/site/styles.css +27 -303
  47. stacktrace_cli/monitor/state.py +5 -0
  48. stacktrace_cli/monitor/watch.py +16 -0
  49. stacktrace_cli/remote/policy_service.py +16 -1
  50. stacktrace_cli/remote/redact.py +0 -113
  51. stacktrace_cli/sessions/lineage.py +24 -0
  52. stacktrace_cli/sessions/protocols.py +81 -8
  53. stacktrace_cli/telemetry/__init__.py +4 -3
  54. stacktrace_cli/telemetry/cli.py +13 -2
  55. stacktrace_cli/telemetry/events.py +256 -16
  56. stacktrace_cli/telemetry/posthog.py +11 -7
  57. stacktrace_cli/telemetry/state.py +32 -3
  58. {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.9.0.dist-info}/METADATA +28 -13
  59. {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.9.0.dist-info}/RECORD +61 -43
  60. {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.9.0.dist-info}/WHEEL +0 -0
  61. {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.9.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.1"
5
+ __version__ = "0.9.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
@@ -62,6 +62,11 @@ class Analysis:
62
62
  #: is not yet countable and that only calls needing no composition are
63
63
  #: listed. `analyse` is always placed.
64
64
  placed: bool = True
65
+ #: `newest_hour` on `analyse_progressively`'s first preview, which reads
66
+ #: only the newest hour of the range; None for the whole range. A page
67
+ #: summing totals needs to tell the two apart, and `placed` cannot: both
68
+ #: previews are unplaced.
69
+ slice: str | None = None
65
70
 
66
71
 
67
72
  #: A count and an optional unit, anchored. Anchored rather than searched
@@ -451,6 +456,7 @@ def analyse_progressively(
451
456
  preview: timedelta | None = PREVIEW_WINDOW,
452
457
  cache: VerdictCache | None = None,
453
458
  attach_advisories: Any = None,
459
+ build_all: Any = None,
454
460
  analyzer: str = NO_ANALYZER,
455
461
  jev_key: str | None = None,
456
462
  history: DriftHistory | None = None,
@@ -463,6 +469,8 @@ def analyse_progressively(
463
469
  `history` is accepted and unused: this path never requests reasoning, so
464
470
  it never adds a point. It is here so the watcher can forward one options
465
471
  dict to both entry points (the test in `tests/monitor/test_watch.py`).
472
+ `build_all` is `analyse`'s own seam, forwarded for the same reason: the
473
+ offline monitor (`monitor/offline.py`) passes one that builds nothing.
466
474
 
467
475
  `analyse` answers once, which is right for a command that prints and exits
468
476
  and wrong for a page meant to look alive. Measured over 339 real sessions
@@ -510,7 +518,10 @@ def analyse_progressively(
510
518
  cutoff = window_end - preview
511
519
  # A window already narrower than the preview is read once, not twice.
512
520
  if cutoff > window_start:
513
- yield _unplaced(read(cutoff), window_start=window_start, window_end=window_end)
521
+ yield replace(
522
+ _unplaced(read(cutoff), window_start=window_start, window_end=window_end),
523
+ slice="newest_hour",
524
+ )
514
525
 
515
526
  sessions = read(window_start)
516
527
  if preview is not None:
@@ -521,6 +532,7 @@ def analyse_progressively(
521
532
  bom_paths=bom_paths,
522
533
  project_map=project_map,
523
534
  attach_advisories=attach_advisories,
535
+ build_all=build_all,
524
536
  ).view
525
537
 
526
538
  def _stage(run: DetectorRun, sessions: tuple[CorrelatedSession, ...]) -> Analysis:
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,11 +406,14 @@ 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
- and a leaked credential cannot share one (ADR-0010).
414
+ and a leaked credential cannot share one (ADR-0010). Cost findings are the
415
+ daemon's, which judges a whole day and a 30-day baseline (ADR-0097);
416
+ `stacktrace findings` shows them.
373
417
 
374
418
  Both stages read the record on this machine. Neither needs a model, a
375
419
  credential or an account, and on a default run nothing derived from a
@@ -378,7 +422,9 @@ def detect(
378
422
  "On a default run" is the whole of the qualification, and it is here rather
379
423
  than left to the flags: `--reasoning --analyzer jev` adds a third stage
380
424
  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,
425
+ and its output; and, for a mutating infrastructure command, that command
426
+ with the user's own requests in the session -- to TypeSafe's Jev
427
+ (ADR-0033). Both flags must be typed,
382
428
  neither has a default, and this output names them when a session is left
383
429
  unjudged, so it is a run a reader can ask for and therefore one this
384
430
  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
@@ -0,0 +1,6 @@
1
+ """What agent work uses, in tokens, and how far above the usual (ADR-0099).
2
+
3
+ Pure functions over the session shape (`sessions/protocols.py`). Nothing here
4
+ reads a file, a clock or the store; hosts pass those in. Nothing here prices:
5
+ every figure is tokens, or a ratio to the usual.
6
+ """