stacktrace-cli 0.0.1__py3-none-any.whl → 0.1.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 (54) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/__main__.py +10 -18
  3. stacktrace_cli/cli.py +314 -0
  4. stacktrace_cli/correlate/__init__.py +1 -0
  5. stacktrace_cli/correlate/acquire.py +373 -0
  6. stacktrace_cli/correlate/composition.py +375 -0
  7. stacktrace_cli/correlate/join.py +380 -0
  8. stacktrace_cli/correlate/observed.py +1836 -0
  9. stacktrace_cli/correlate/orchestrate.py +417 -0
  10. stacktrace_cli/correlate/project_map.py +81 -0
  11. stacktrace_cli/correlate/record.py +279 -0
  12. stacktrace_cli/correlate/render.py +766 -0
  13. stacktrace_cli/detector/__init__.py +1 -0
  14. stacktrace_cli/detector/analyzer.py +207 -0
  15. stacktrace_cli/detector/cache.py +483 -0
  16. stacktrace_cli/detector/deterministic.py +606 -0
  17. stacktrace_cli/detector/finding.py +247 -0
  18. stacktrace_cli/detector/markers.py +158 -0
  19. stacktrace_cli/detector/priors.py +502 -0
  20. stacktrace_cli/detector/prompts/__init__.py +36 -0
  21. stacktrace_cli/detector/prompts/v1/exclusions.md +36 -0
  22. stacktrace_cli/detector/prompts/v1/framing.md +23 -0
  23. stacktrace_cli/detector/prompts/v1/stacktrace-deceptive-completion.md +12 -0
  24. stacktrace_cli/detector/prompts/v1/stacktrace-injected-instruction-followed.md +14 -0
  25. stacktrace_cli/detector/prompts/v1/stacktrace-intent-drift.md +11 -0
  26. stacktrace_cli/detector/reasoning.py +1180 -0
  27. stacktrace_cli/detector/render.py +310 -0
  28. stacktrace_cli/detector/rules.py +136 -0
  29. stacktrace_cli/detector/run.py +453 -0
  30. stacktrace_cli/detector/secrets.py +203 -0
  31. stacktrace_cli/detector/verdict.py +266 -0
  32. stacktrace_cli/remote/__init__.py +1 -0
  33. stacktrace_cli/remote/cli.py +475 -0
  34. stacktrace_cli/remote/client.py +393 -0
  35. stacktrace_cli/remote/config.py +106 -0
  36. stacktrace_cli/remote/detect_payload.py +262 -0
  37. stacktrace_cli/remote/payload.py +435 -0
  38. stacktrace_cli/remote/policy.py +88 -0
  39. stacktrace_cli/remote/redact.py +649 -0
  40. stacktrace_cli/remote/spool.py +446 -0
  41. stacktrace_cli/remote/sync.py +558 -0
  42. stacktrace_cli/remote/sync_detect.py +409 -0
  43. stacktrace_cli/remote/upload_contract.py +973 -0
  44. stacktrace_cli/sessions/__init__.py +1 -0
  45. stacktrace_cli/sessions/access.py +79 -0
  46. stacktrace_cli/sessions/outcome.py +65 -0
  47. stacktrace_cli/sessions/protocols.py +204 -0
  48. stacktrace_cli/sessions/render.py +295 -0
  49. stacktrace_cli-0.1.0.dist-info/METADATA +228 -0
  50. stacktrace_cli-0.1.0.dist-info/RECORD +52 -0
  51. stacktrace_cli-0.0.1.dist-info/METADATA +0 -39
  52. stacktrace_cli-0.0.1.dist-info/RECORD +0 -6
  53. {stacktrace_cli-0.0.1.dist-info → stacktrace_cli-0.1.0.dist-info}/WHEEL +0 -0
  54. {stacktrace_cli-0.0.1.dist-info → stacktrace_cli-0.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,3 @@
1
1
  """Placeholder CLI package for Stacktrace.ai."""
2
2
 
3
- __version__ = "0.0.1"
3
+ __version__ = "0.1.0"
@@ -1,24 +1,16 @@
1
- """Entry point for the ``stacktrace`` command."""
1
+ """Entry point for the ``stacktrace`` command.
2
2
 
3
- from __future__ import annotations
4
-
5
- import argparse
6
-
7
- from . import __version__
8
-
9
- HOMEPAGE = "https://stacktrace.ai"
3
+ `[project.scripts]` still names `stacktrace_cli.__main__:main`, which now
4
+ resolves to the Click group in `stacktrace_cli.cli`, so both the console script
5
+ and `python -m stacktrace_cli` reach the same object and the published
6
+ entry-point string does not change between releases.
7
+ """
10
8
 
9
+ from __future__ import annotations
11
10
 
12
- def main(argv: list[str] | None = None) -> int:
13
- parser = argparse.ArgumentParser(
14
- prog="stacktrace",
15
- description=f"Stacktrace.ai CLI (pre-alpha). See {HOMEPAGE}",
16
- )
17
- parser.add_argument("--version", action="version", version=f"stacktrace {__version__}")
18
- parser.parse_args(argv)
19
- print(f"stacktrace {__version__} — pre-alpha placeholder. See {HOMEPAGE}")
20
- return 0
11
+ from .cli import main
21
12
 
13
+ __all__ = ["main"]
22
14
 
23
15
  if __name__ == "__main__":
24
- raise SystemExit(main())
16
+ main()
stacktrace_cli/cli.py ADDED
@@ -0,0 +1,314 @@
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 .correlate.orchestrate import acquire_correlated_view
29
+ from .correlate.project_map import parse_mapping
30
+ from .detector.cache import VerdictCache, default_directory
31
+ from .detector.render import render_json as render_detections_json
32
+ from .detector.render import render_text as render_detections_text
33
+ from .detector.run import DEFAULT_BUDGET, DEFAULT_SAMPLE_BUDGET, run_detector
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 _show_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
54
+ # Click invokes an eager option's callback on every invocation of the
55
+ # group, with `value=False` when the flag was absent, so an unguarded
56
+ # callback would print the version and exit before any subcommand ran.
57
+ # click's own `version_option` callback opens with the same two guards.
58
+ if not value or ctx.resilient_parsing:
59
+ return
60
+ click.echo(f"stacktrace {__version__} (openaca {version('openaca')})")
61
+ ctx.exit()
62
+
63
+
64
+ class SectionedGroup(click.Group):
65
+ """A group whose command listing is split into delegated and native sections.
66
+
67
+ Click has no notion of command sections and the distinction matters here: a
68
+ delegated command's own ``--help`` describes OpenACA and its options are
69
+ OpenACA's, which a flat alphabetical listing tells nobody. The partition is
70
+ derived from `PASSTHROUGH`, so registering a native command puts it in the
71
+ right section with no edit to this class.
72
+ """
73
+
74
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
75
+ commands: list[tuple[str, click.Command]] = []
76
+ for name in self.list_commands(ctx):
77
+ cmd = self.get_command(ctx, name)
78
+ if cmd is None or cmd.hidden:
79
+ continue
80
+ commands.append((name, cmd))
81
+ if not commands:
82
+ return
83
+
84
+ limit = formatter.width - 6 - max(len(name) for name, _ in commands)
85
+ delegated = [
86
+ (name, cmd.get_short_help_str(limit)) for name, cmd in commands if name in PASSTHROUGH
87
+ ]
88
+ native = [
89
+ (name, cmd.get_short_help_str(limit))
90
+ for name, cmd in commands
91
+ if name not in PASSTHROUGH
92
+ ]
93
+
94
+ for heading, rows in (("Analysis (OpenACA)", delegated), ("Stacktrace", native)):
95
+ if not rows:
96
+ continue
97
+ with formatter.section(heading):
98
+ formatter.write_dl(rows)
99
+
100
+
101
+ @click.group(cls=SectionedGroup)
102
+ @click.option(
103
+ "--version",
104
+ is_flag=True,
105
+ expose_value=False,
106
+ is_eager=True,
107
+ callback=_show_version,
108
+ help="Show the stacktrace and OpenACA versions and exit.",
109
+ )
110
+ def main() -> None:
111
+ """Stacktrace: agent composition analysis, detection and upload."""
112
+
113
+
114
+ @click.command()
115
+ @click.option(
116
+ "--agent-kind",
117
+ "agent_kinds",
118
+ multiple=True,
119
+ help="Only this agent kind. Repeatable. Default: every kind.",
120
+ )
121
+ @click.option("--since", default="14d", show_default=True, help="Window, as a day count.")
122
+ @click.option(
123
+ "--include-content",
124
+ is_flag=True,
125
+ default=False,
126
+ help="Include prompts, tool arguments and results. Off by default: a rendered "
127
+ "document leaves the process, and the Correlator reads the model directly.",
128
+ )
129
+ @click.option(
130
+ "--format",
131
+ "output_format",
132
+ type=click.Choice(["text", "json"]),
133
+ default="text",
134
+ show_default=True,
135
+ )
136
+ @click.option(
137
+ "--detail",
138
+ is_flag=True,
139
+ default=False,
140
+ help="Show the session trail. Default: the summary alone.",
141
+ )
142
+ @click.option(
143
+ "--root",
144
+ type=click.Path(file_okay=False, path_type=Path),
145
+ default=None,
146
+ help="Read agent transcripts from here instead of the default location.",
147
+ )
148
+ def sessions(
149
+ agent_kinds: tuple[str, ...],
150
+ since: str,
151
+ output_format: str,
152
+ include_content: bool,
153
+ detail: bool,
154
+ root: Path | None,
155
+ ) -> None:
156
+ """Print what the agents on this machine actually did."""
157
+ view = collect_sessions(list(agent_kinds) or None, _parse_since(since), root=root)
158
+ output = (
159
+ render_json(view, include_content)
160
+ if output_format == "json"
161
+ else render_text(view, include_content, detail)
162
+ )
163
+ click.echo(output, nl=not output.endswith("\n"))
164
+
165
+
166
+ @click.command()
167
+ @click.option("--agent-kind", "agent_kinds", multiple=True, help="Limit to these agent kinds.")
168
+ @click.option("--since", default="7d", show_default=True, help="How far back to read.")
169
+ @click.option(
170
+ "--bom",
171
+ "bom_paths",
172
+ multiple=True,
173
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
174
+ help="Use this Agent BOM instead of scanning for its kind.",
175
+ )
176
+ @click.option(
177
+ "--format",
178
+ "output_format",
179
+ type=click.Choice(["text", "json"]),
180
+ default="text",
181
+ show_default=True,
182
+ )
183
+ @click.option(
184
+ "--detail",
185
+ is_flag=True,
186
+ default=False,
187
+ help="Show every detection and all of its evidence. Default: the summary alone.",
188
+ )
189
+ @click.option(
190
+ "--escalate/--no-escalate",
191
+ default=True,
192
+ show_default=True,
193
+ help="Send flagged sessions to the agent's own CLI for semantic analysis. On "
194
+ "by default, capped by --budget. It is the only stage that sends anything "
195
+ "session-derived off this machine, and it spends the developer's own "
196
+ "provider quota, so --no-escalate turns it off and the cap keeps a noisy "
197
+ "day bounded. It does not make the run offline: correlation runs first "
198
+ "either way and matches advisories by sending the package coordinates of "
199
+ "the components a session invoked to osv.dev.",
200
+ )
201
+ @click.option(
202
+ "--budget",
203
+ type=click.IntRange(min=1),
204
+ default=DEFAULT_BUDGET,
205
+ show_default=True,
206
+ help="How many sessions one run may send for inference.",
207
+ )
208
+ @click.option(
209
+ "--sample-budget",
210
+ type=click.IntRange(min=0),
211
+ default=DEFAULT_SAMPLE_BUDGET,
212
+ show_default=True,
213
+ help="Quiet sessions to analyse per run with no evidence behind them. Off "
214
+ "by default: it is the only route that spends inference with no evidence, "
215
+ "so it is asked for rather than assumed. Shares the --budget cap.",
216
+ )
217
+ @click.option(
218
+ "--cache/--no-cache",
219
+ default=True,
220
+ show_default=True,
221
+ help="Reuse a stored verdict for a session that has not changed. Holds rule "
222
+ "ids, grades and span identifiers only — never conversation (ADR-0011).",
223
+ )
224
+ @click.option(
225
+ "--project-map",
226
+ "project_map",
227
+ multiple=True,
228
+ metavar="OLD=NEW",
229
+ help="Where a project a session ran in lives now, for a directory that has "
230
+ "moved since. Applies to OLD and everything under it, only where OLD no "
231
+ "longer exists; repeatable, most specific wins. Every result is marked with "
232
+ "where it was mapped from.",
233
+ )
234
+ @click.option(
235
+ "--root",
236
+ type=click.Path(file_okay=False, path_type=Path),
237
+ default=None,
238
+ help="Read agent transcripts from here instead of the default location.",
239
+ )
240
+ def detect(
241
+ agent_kinds: tuple[str, ...],
242
+ since: str,
243
+ bom_paths: tuple[Path, ...],
244
+ output_format: str,
245
+ detail: bool,
246
+ escalate: bool,
247
+ budget: int,
248
+ sample_budget: int,
249
+ cache: bool,
250
+ project_map: tuple[str, ...],
251
+ root: Path | None,
252
+ ) -> None:
253
+ """Find security and reliability findings in what agents did.
254
+
255
+ Four kinds of finding, under two families: a credential reaching an
256
+ outbound call and an injected instruction followed are security; a
257
+ vulnerable component actually reached is security with a vulnerability
258
+ behind it; a stalled loop and a hung call are reliability. The two
259
+ families carry their own severity ladders, because a stalled loop and a
260
+ leaked credential cannot share one (ADR-0010).
261
+
262
+ Two stages need no model or credential. The third sends flagged sessions to
263
+ the agent's own CLI: on by default and capped by --budget, with
264
+ --no-escalate to turn it off.
265
+
266
+ That third stage is the one that sends session content off this machine:
267
+ the agent's own CLI hands the prompts, arguments and results a rule needs
268
+ to the provider it is already authenticated against (ADR-0004 -- provider
269
+ affinity, so a transcript goes back to the vendor that produced it, and
270
+ there is no fallback to any other). --no-escalate leaves the two stages
271
+ that send nothing.
272
+
273
+ One network call happens before any of them regardless of --no-escalate:
274
+ correlation matches advisories by sending the package coordinates of the
275
+ components a session invoked to osv.dev. Coordinates only -- never a
276
+ prompt, an argument or a result.
277
+ """
278
+ view = collect_sessions(list(agent_kinds) or None, _parse_since(since), root=root)
279
+ try:
280
+ acquired = acquire_correlated_view(
281
+ view, bom_paths=bom_paths, project_map=tuple(parse_mapping(m) for m in project_map)
282
+ )
283
+ except ValueError as error:
284
+ raise click.ClickException(str(error)) from error
285
+
286
+ result = run_detector(
287
+ acquired.view,
288
+ escalate=escalate,
289
+ budget=budget,
290
+ sample_budget=sample_budget,
291
+ # Only ever consulted when stage 3 runs: there is nothing to reuse
292
+ # otherwise, and opening a directory to discover that is waste.
293
+ cache=VerdictCache(default_directory()) if cache and escalate else None,
294
+ )
295
+ output = (
296
+ render_detections_json(result)
297
+ if output_format == "json"
298
+ else render_detections_text(result, detail)
299
+ )
300
+ click.echo(output, nl=not output.endswith("\n"))
301
+
302
+
303
+ # Named, never discovered: an upstream release must not be able to enlarge this
304
+ # CLI. A missing name is a `KeyError` at import time, which names the command
305
+ # and the line, and lands at upgrade time.
306
+ for _name in PASSTHROUGH:
307
+ main.add_command(openaca_cli.commands[_name], _name)
308
+
309
+ # Native, registered the ordinary way and after the loop, so a name collision
310
+ # resolves in the native command's favour. The one recorded overlap is
311
+ # openaca's own `remote`, which is never mounted here.
312
+ main.add_command(remote_cmd, "remote")
313
+ main.add_command(sessions)
314
+ main.add_command(detect)
@@ -0,0 +1 @@
1
+ """Correlation: the join between what an agent did and what it is built from."""