stacktrace-cli 0.0.1__py3-none-any.whl → 0.2.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 (69) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/__main__.py +10 -18
  3. stacktrace_cli/analysis.py +375 -0
  4. stacktrace_cli/cli.py +470 -0
  5. stacktrace_cli/correlate/__init__.py +1 -0
  6. stacktrace_cli/correlate/acquire.py +369 -0
  7. stacktrace_cli/correlate/composition.py +434 -0
  8. stacktrace_cli/correlate/join.py +426 -0
  9. stacktrace_cli/correlate/observed.py +1836 -0
  10. stacktrace_cli/correlate/orchestrate.py +451 -0
  11. stacktrace_cli/correlate/project_map.py +81 -0
  12. stacktrace_cli/correlate/record.py +287 -0
  13. stacktrace_cli/correlate/render.py +766 -0
  14. stacktrace_cli/detector/__init__.py +1 -0
  15. stacktrace_cli/detector/analyzer.py +207 -0
  16. stacktrace_cli/detector/cache.py +483 -0
  17. stacktrace_cli/detector/deterministic.py +654 -0
  18. stacktrace_cli/detector/finding.py +356 -0
  19. stacktrace_cli/detector/markers.py +158 -0
  20. stacktrace_cli/detector/priors.py +502 -0
  21. stacktrace_cli/detector/prompts/__init__.py +36 -0
  22. stacktrace_cli/detector/prompts/v1/exclusions.md +36 -0
  23. stacktrace_cli/detector/prompts/v1/framing.md +23 -0
  24. stacktrace_cli/detector/prompts/v1/stacktrace-deceptive-completion.md +12 -0
  25. stacktrace_cli/detector/prompts/v1/stacktrace-injected-instruction-followed.md +14 -0
  26. stacktrace_cli/detector/prompts/v1/stacktrace-intent-drift.md +11 -0
  27. stacktrace_cli/detector/reasoning.py +1180 -0
  28. stacktrace_cli/detector/render.py +370 -0
  29. stacktrace_cli/detector/rules.py +136 -0
  30. stacktrace_cli/detector/run.py +821 -0
  31. stacktrace_cli/detector/secrets.py +203 -0
  32. stacktrace_cli/detector/verdict.py +266 -0
  33. stacktrace_cli/monitor/__init__.py +12 -0
  34. stacktrace_cli/monitor/escalate.py +172 -0
  35. stacktrace_cli/monitor/render.py +252 -0
  36. stacktrace_cli/monitor/server.py +477 -0
  37. stacktrace_cli/monitor/site/app.js +1045 -0
  38. stacktrace_cli/monitor/site/fonts/OFL.txt +210 -0
  39. stacktrace_cli/monitor/site/fonts/dm-mono-400-latin.woff2 +0 -0
  40. stacktrace_cli/monitor/site/fonts/dm-mono-500-latin.woff2 +0 -0
  41. stacktrace_cli/monitor/site/fonts/dm-sans-latin.woff2 +0 -0
  42. stacktrace_cli/monitor/site/index.html +104 -0
  43. stacktrace_cli/monitor/site/styles.css +628 -0
  44. stacktrace_cli/monitor/state.py +102 -0
  45. stacktrace_cli/monitor/verdicts.py +52 -0
  46. stacktrace_cli/monitor/watch.py +349 -0
  47. stacktrace_cli/remote/__init__.py +1 -0
  48. stacktrace_cli/remote/cli.py +475 -0
  49. stacktrace_cli/remote/client.py +393 -0
  50. stacktrace_cli/remote/config.py +106 -0
  51. stacktrace_cli/remote/detect_payload.py +334 -0
  52. stacktrace_cli/remote/payload.py +435 -0
  53. stacktrace_cli/remote/policy.py +88 -0
  54. stacktrace_cli/remote/redact.py +660 -0
  55. stacktrace_cli/remote/spool.py +446 -0
  56. stacktrace_cli/remote/sync.py +558 -0
  57. stacktrace_cli/remote/sync_detect.py +446 -0
  58. stacktrace_cli/remote/upload_contract.py +994 -0
  59. stacktrace_cli/sessions/__init__.py +1 -0
  60. stacktrace_cli/sessions/access.py +79 -0
  61. stacktrace_cli/sessions/outcome.py +65 -0
  62. stacktrace_cli/sessions/protocols.py +204 -0
  63. stacktrace_cli/sessions/render.py +295 -0
  64. stacktrace_cli-0.2.0.dist-info/METADATA +228 -0
  65. stacktrace_cli-0.2.0.dist-info/RECORD +67 -0
  66. stacktrace_cli-0.0.1.dist-info/METADATA +0 -39
  67. stacktrace_cli-0.0.1.dist-info/RECORD +0 -6
  68. {stacktrace_cli-0.0.1.dist-info → stacktrace_cli-0.2.0.dist-info}/WHEEL +0 -0
  69. {stacktrace_cli-0.0.1.dist-info → stacktrace_cli-0.2.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/cli.py ADDED
@@ -0,0 +1,470 @@
1
+ """The ``stacktrace`` command group.
2
+
3
+ Two kinds of command live here (ADR-0021, `docs/adrs/0021-two-command-kinds.md`):
4
+
5
+ - **Pass-through** — OpenACA's own Click command object, mounted under this
6
+ group. It declares no parameter of its own, ever, and nothing here parses its
7
+ arguments; Click dispatches them to OpenACA's parser. The object registered
8
+ under ``stacktrace scan`` *is* ``openaca scan``.
9
+ - **Native** — a command this package implements, registered the ordinary way.
10
+ If it needs OpenACA it calls ``openaca.core`` (ADR-0020).
11
+
12
+ `openaca.cli` is a supported import path published for exactly this use
13
+ (ADR-0020, `docs/adrs/0020-openaca-consumption-boundary.md`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from datetime import UTC, datetime, timedelta
20
+ from importlib.metadata import version
21
+ from pathlib import Path
22
+ from typing import Final
23
+
24
+ import click
25
+ from openaca.cli import main as openaca_cli
26
+
27
+ from . import __version__
28
+ from .analysis import analyse, parse_since
29
+ from .detector.cache import VerdictCache, default_directory
30
+ from .detector.render import render_json as render_detections_json
31
+ from .detector.render import render_text as render_detections_text
32
+ from .detector.run import DEFAULT_BUDGET, DEFAULT_SAMPLE_BUDGET
33
+ from .monitor.server import serve
34
+ from .remote.cli import main as remote_cmd
35
+ from .sessions.access import collect_sessions
36
+ from .sessions.render import render_json, render_text
37
+
38
+ PASSTHROUGH: Final = ("scan", "bom", "policy")
39
+
40
+ HOMEPAGE = "https://stacktrace.ai"
41
+
42
+ _SINCE_PATTERN = re.compile(r"^(\d+)d$")
43
+
44
+
45
+ def _parse_since(value: str) -> datetime:
46
+ """`14d` -> an aware UTC cut-off."""
47
+ match = _SINCE_PATTERN.match(value.strip())
48
+ if not match:
49
+ raise click.BadParameter(f"expected a day count like '14d', got {value!r}")
50
+ return datetime.now(UTC) - timedelta(days=int(match.group(1)))
51
+
52
+
53
+ def _window_start(ctx: click.Context, param: click.Parameter, value: str) -> datetime:
54
+ """`detect`'s window, resolved by this command's own parser.
55
+
56
+ The window otherwise reaches `analyse` as a string and is parsed there, by
57
+ the parser that also answers the upload path — whose value comes from a
58
+ config file rather than a shell, so it is more forgiving about the spelling
59
+ and stricter about the count. Applying it here as well would quietly change
60
+ what this command accepts, so the cutoff travels already resolved.
61
+ """
62
+ return _parse_since(value)
63
+
64
+
65
+ def _day_count(ctx: click.Context, param: click.Parameter, value: str) -> str:
66
+ """Refuse here every `--since` the pipeline would refuse later.
67
+
68
+ The window reaches `analyse` as a string and is parsed there, once for every
69
+ caller. That parser also answers the upload path, whose value comes from a
70
+ config file rather than a shell, so it is the more forgiving of the two —
71
+ and the narrower spelling stays here, where a mistyped option is a usage
72
+ error with the usage line attached rather than a command that started,
73
+ read nothing and failed.
74
+ """
75
+ _parse_since(value)
76
+ try:
77
+ parse_since(value)
78
+ except ValueError as error:
79
+ raise click.BadParameter(str(error)) from error
80
+ return value
81
+
82
+
83
+ def _show_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
84
+ # Click invokes an eager option's callback on every invocation of the
85
+ # group, with `value=False` when the flag was absent, so an unguarded
86
+ # callback would print the version and exit before any subcommand ran.
87
+ # click's own `version_option` callback opens with the same two guards.
88
+ if not value or ctx.resilient_parsing:
89
+ return
90
+ click.echo(f"stacktrace {__version__} (openaca {version('openaca')})")
91
+ ctx.exit()
92
+
93
+
94
+ class SectionedGroup(click.Group):
95
+ """A group whose command listing is split into delegated and native sections.
96
+
97
+ Click has no notion of command sections and the distinction matters here: a
98
+ delegated command's own ``--help`` describes OpenACA and its options are
99
+ OpenACA's, which a flat alphabetical listing tells nobody. The partition is
100
+ derived from `PASSTHROUGH`, so registering a native command puts it in the
101
+ right section with no edit to this class.
102
+ """
103
+
104
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
105
+ commands: list[tuple[str, click.Command]] = []
106
+ for name in self.list_commands(ctx):
107
+ cmd = self.get_command(ctx, name)
108
+ if cmd is None or cmd.hidden:
109
+ continue
110
+ commands.append((name, cmd))
111
+ if not commands:
112
+ return
113
+
114
+ limit = formatter.width - 6 - max(len(name) for name, _ in commands)
115
+ delegated = [
116
+ (name, cmd.get_short_help_str(limit)) for name, cmd in commands if name in PASSTHROUGH
117
+ ]
118
+ native = [
119
+ (name, cmd.get_short_help_str(limit))
120
+ for name, cmd in commands
121
+ if name not in PASSTHROUGH
122
+ ]
123
+
124
+ for heading, rows in (("Analysis (OpenACA)", delegated), ("Stacktrace", native)):
125
+ if not rows:
126
+ continue
127
+ with formatter.section(heading):
128
+ formatter.write_dl(rows)
129
+
130
+
131
+ @click.group(cls=SectionedGroup)
132
+ @click.option(
133
+ "--version",
134
+ is_flag=True,
135
+ expose_value=False,
136
+ is_eager=True,
137
+ callback=_show_version,
138
+ help="Show the stacktrace and OpenACA versions and exit.",
139
+ )
140
+ def main() -> None:
141
+ """Stacktrace: agent composition analysis, detection and upload."""
142
+
143
+
144
+ @click.command()
145
+ @click.option(
146
+ "--agent-kind",
147
+ "agent_kinds",
148
+ multiple=True,
149
+ help="Only this agent kind. Repeatable. Default: every kind.",
150
+ )
151
+ @click.option("--since", default="14d", show_default=True, help="Window, as a day count.")
152
+ @click.option(
153
+ "--include-content",
154
+ is_flag=True,
155
+ default=False,
156
+ help="Include prompts, tool arguments and results. Off by default: a rendered "
157
+ "document leaves the process, and the Correlator reads the model directly.",
158
+ )
159
+ @click.option(
160
+ "--format",
161
+ "output_format",
162
+ type=click.Choice(["text", "json"]),
163
+ default="text",
164
+ show_default=True,
165
+ )
166
+ @click.option(
167
+ "--detail",
168
+ is_flag=True,
169
+ default=False,
170
+ help="Show the session trail. Default: the summary alone.",
171
+ )
172
+ @click.option(
173
+ "--root",
174
+ type=click.Path(file_okay=False, path_type=Path),
175
+ default=None,
176
+ help="Read agent transcripts from here instead of the default location.",
177
+ )
178
+ def sessions(
179
+ agent_kinds: tuple[str, ...],
180
+ since: str,
181
+ output_format: str,
182
+ include_content: bool,
183
+ detail: bool,
184
+ root: Path | None,
185
+ ) -> None:
186
+ """Print what the agents on this machine actually did."""
187
+ view = collect_sessions(list(agent_kinds) or None, _parse_since(since), root=root)
188
+ output = (
189
+ render_json(view, include_content)
190
+ if output_format == "json"
191
+ else render_text(view, include_content, detail)
192
+ )
193
+ click.echo(output, nl=not output.endswith("\n"))
194
+
195
+
196
+ @click.command()
197
+ @click.option("--agent-kind", "agent_kinds", multiple=True, help="Limit to these agent kinds.")
198
+ @click.option(
199
+ "--since",
200
+ default="7d",
201
+ show_default=True,
202
+ callback=_window_start,
203
+ help="How far back to read.",
204
+ )
205
+ @click.option(
206
+ "--bom",
207
+ "bom_paths",
208
+ multiple=True,
209
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
210
+ help="Use this Agent BOM instead of scanning for its kind.",
211
+ )
212
+ @click.option(
213
+ "--format",
214
+ "output_format",
215
+ type=click.Choice(["text", "json"]),
216
+ default="text",
217
+ show_default=True,
218
+ )
219
+ @click.option(
220
+ "--detail",
221
+ is_flag=True,
222
+ default=False,
223
+ help="Show every detection and all of its evidence. Default: the summary alone.",
224
+ )
225
+ @click.option(
226
+ "--escalate/--no-escalate",
227
+ default=True,
228
+ show_default=True,
229
+ help="Send flagged sessions to the agent's own CLI for semantic analysis. On "
230
+ "by default, capped by --budget. 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 --no-escalate turns it off and the cap keeps a noisy "
233
+ "day bounded. It does not make the run offline: correlation runs first "
234
+ "either way and matches advisories by sending the package coordinates of "
235
+ "the components a session invoked to osv.dev.",
236
+ )
237
+ @click.option(
238
+ "--budget",
239
+ type=click.IntRange(min=1),
240
+ default=DEFAULT_BUDGET,
241
+ show_default=True,
242
+ help="How many sessions one run may send for inference.",
243
+ )
244
+ @click.option(
245
+ "--sample-budget",
246
+ type=click.IntRange(min=0),
247
+ default=DEFAULT_SAMPLE_BUDGET,
248
+ show_default=True,
249
+ help="Quiet sessions to analyse per run with no evidence behind them. Off "
250
+ "by default: it is the only route that spends inference with no evidence, "
251
+ "so it is asked for rather than assumed. Shares the --budget cap.",
252
+ )
253
+ @click.option(
254
+ "--cache/--no-cache",
255
+ default=True,
256
+ show_default=True,
257
+ help="Reuse a stored verdict for a session that has not changed. Holds rule "
258
+ "ids, grades and span identifiers only — never conversation (ADR-0011).",
259
+ )
260
+ @click.option(
261
+ "--project-map",
262
+ "project_map",
263
+ multiple=True,
264
+ metavar="OLD=NEW",
265
+ help="Where a project a session ran in lives now, for a directory that has "
266
+ "moved since. Applies to OLD and everything under it, only where OLD no "
267
+ "longer exists; repeatable, most specific wins. Every result is marked with "
268
+ "where it was mapped from.",
269
+ )
270
+ @click.option(
271
+ "--root",
272
+ type=click.Path(file_okay=False, path_type=Path),
273
+ default=None,
274
+ help="Read agent transcripts from here instead of the default location.",
275
+ )
276
+ def detect(
277
+ agent_kinds: tuple[str, ...],
278
+ since: datetime,
279
+ bom_paths: tuple[Path, ...],
280
+ output_format: str,
281
+ detail: bool,
282
+ escalate: bool,
283
+ budget: int,
284
+ sample_budget: int,
285
+ cache: bool,
286
+ project_map: tuple[str, ...],
287
+ root: Path | None,
288
+ ) -> None:
289
+ """Find security and reliability findings in what agents did.
290
+
291
+ Four kinds of finding, under two families: a credential reaching an
292
+ outbound call and an injected instruction followed are security; a
293
+ vulnerable component actually reached is security with a vulnerability
294
+ behind it; a stalled loop and a hung call are reliability. The two
295
+ families carry their own severity ladders, because a stalled loop and a
296
+ leaked credential cannot share one (ADR-0010).
297
+
298
+ Two stages need no model or credential. The third sends flagged sessions to
299
+ the agent's own CLI: on by default and capped by --budget, with
300
+ --no-escalate to turn it off.
301
+
302
+ That third stage is the one that sends session content off this machine:
303
+ the agent's own CLI hands the prompts, arguments and results a rule needs
304
+ to the provider it is already authenticated against (ADR-0004 -- provider
305
+ affinity, so a transcript goes back to the vendor that produced it, and
306
+ there is no fallback to any other). --no-escalate leaves the two stages
307
+ that send nothing.
308
+
309
+ One network call happens before any of them regardless of --no-escalate:
310
+ correlation matches advisories by sending the package coordinates of the
311
+ components a session invoked to osv.dev. Coordinates only -- never a
312
+ prompt, an argument or a result.
313
+ """
314
+ try:
315
+ # One call, because the order it runs in is `analysis.py`'s to know:
316
+ # `monitor` and `sync detect` read the same pipeline and must not be
317
+ # able to disagree with this command about what it found.
318
+ analysis = analyse(
319
+ agent_kinds=agent_kinds,
320
+ since=since,
321
+ bom_paths=bom_paths,
322
+ project_map=project_map,
323
+ root=root,
324
+ escalate=escalate,
325
+ budget=budget,
326
+ sample_budget=sample_budget,
327
+ # Only ever consulted when stage 3 runs: there is nothing to reuse
328
+ # otherwise, and opening a directory to discover that is waste.
329
+ cache=VerdictCache(default_directory()) if cache and escalate else None,
330
+ )
331
+ except ValueError as error:
332
+ raise click.ClickException(str(error)) from error
333
+ result = analysis.run
334
+ output = (
335
+ render_detections_json(result)
336
+ if output_format == "json"
337
+ else render_detections_text(result, detail)
338
+ )
339
+ click.echo(output, nl=not output.endswith("\n"))
340
+
341
+
342
+ @click.command()
343
+ @click.option("--port", type=int, default=0, help="Serve on this port instead of an unused one.")
344
+ @click.option(
345
+ "--host",
346
+ default="127.0.0.1",
347
+ show_default=True,
348
+ help="Loopback only. This page renders working directories and matched "
349
+ "values, so a non-loopback address is refused rather than warned about.",
350
+ )
351
+ @click.option(
352
+ "--interval",
353
+ # Above zero, not merely non-negative: the wait between passes is what
354
+ # makes this page free to leave open, and `Event.wait(0)` returns at once —
355
+ # a zero or negative interval walks the transcript tree in a hot loop.
356
+ type=click.FloatRange(min=0, min_open=True),
357
+ default=4.0,
358
+ show_default=True,
359
+ help="Seconds between checks for new agent activity. A pass is skipped "
360
+ "entirely when nothing on disk has changed.",
361
+ )
362
+ @click.option(
363
+ "--escalate/--no-escalate",
364
+ default=False,
365
+ show_default=True,
366
+ help="Let the page send one session to the agent's own CLI for semantic "
367
+ "analysis. Off by default: stage 3 spends your provider quota, and a page "
368
+ "left open is the wrong place for that to be implicit. Capped by --budget.",
369
+ )
370
+ @click.option(
371
+ "--budget",
372
+ type=click.IntRange(min=1),
373
+ default=DEFAULT_BUDGET,
374
+ show_default=True,
375
+ help="Escalations this monitor may spend in total, if --escalate is on.",
376
+ )
377
+ @click.option(
378
+ "--no-open", is_flag=True, default=False, help="Print the URL, do not open a browser."
379
+ )
380
+ @click.option("--agent-kind", "agent_kinds", multiple=True, help="Limit to these agent kinds.")
381
+ @click.option(
382
+ "--since",
383
+ default="3d",
384
+ show_default=True,
385
+ callback=_day_count,
386
+ help="How far back to read. Shorter than `detect`'s week deliberately: the "
387
+ "whole window is collected and correlated before the page can render "
388
+ "anything — measured at ~2.5s for a day and ~6s for a week of a busy "
389
+ "machine — and a live view is about what is happening rather than what "
390
+ "happened. Widen it when you want the history.",
391
+ )
392
+ @click.option(
393
+ "--bom",
394
+ "bom_paths",
395
+ multiple=True,
396
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
397
+ help="Use this Agent BOM instead of scanning for its kind.",
398
+ )
399
+ @click.option(
400
+ "--project-map",
401
+ "project_map",
402
+ multiple=True,
403
+ metavar="OLD=NEW",
404
+ help="Where a project a session ran in lives now, for a directory that has moved.",
405
+ )
406
+ @click.option(
407
+ "--root",
408
+ type=click.Path(file_okay=False, path_type=Path),
409
+ default=None,
410
+ help="Read agent transcripts from here instead of the default location.",
411
+ )
412
+ def monitor(
413
+ port: int,
414
+ host: str,
415
+ interval: float,
416
+ escalate: bool,
417
+ budget: int,
418
+ no_open: bool,
419
+ agent_kinds: tuple[str, ...],
420
+ since: str,
421
+ bom_paths: tuple[Path, ...],
422
+ project_map: tuple[str, ...],
423
+ root: Path | None,
424
+ ) -> None:
425
+ """Watch this machine's agents in a browser, live.
426
+
427
+ Serves a local page — a summary, what your agents are doing, and what the
428
+ detector makes of it — refreshing as they work. Everything on it is
429
+ computed in this process by the same code `stacktrace detect` runs; the
430
+ page renders and never judges.
431
+
432
+ Loopback only, and free to leave open: a pass is skipped when nothing has
433
+ changed, advisory lookups are asked once per component, and the two stages
434
+ that need no model are the only ones that run. --escalate adds a button
435
+ that spends your provider quota, off unless asked for.
436
+ """
437
+ try:
438
+ code = serve(
439
+ host=host,
440
+ port=port,
441
+ interval=interval,
442
+ escalate=escalate,
443
+ budget=budget,
444
+ open_browser=not no_open,
445
+ echo=click.echo,
446
+ agent_kinds=agent_kinds,
447
+ since=since,
448
+ bom_paths=bom_paths,
449
+ project_map=project_map,
450
+ root=root,
451
+ )
452
+ except (OSError, ValueError) as error:
453
+ raise click.ClickException(str(error)) from error
454
+ if code:
455
+ raise click.exceptions.Exit(code)
456
+
457
+
458
+ # Named, never discovered: an upstream release must not be able to enlarge this
459
+ # CLI. A missing name is a `KeyError` at import time, which names the command
460
+ # and the line, and lands at upgrade time.
461
+ for _name in PASSTHROUGH:
462
+ main.add_command(openaca_cli.commands[_name], _name)
463
+
464
+ # Native, registered the ordinary way and after the loop, so a name collision
465
+ # resolves in the native command's favour. The one recorded overlap is
466
+ # openaca's own `remote`, which is never mounted here.
467
+ main.add_command(remote_cmd, "remote")
468
+ main.add_command(sessions)
469
+ main.add_command(detect)
470
+ main.add_command(monitor)
@@ -0,0 +1 @@
1
+ """Correlation: the join between what an agent did and what it is built from."""