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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/analysis.py +202 -20
- stacktrace_cli/build.py +130 -0
- stacktrace_cli/cli.py +137 -55
- stacktrace_cli/correlate/acquire.py +23 -2
- stacktrace_cli/correlate/orchestrate.py +73 -9
- stacktrace_cli/correlate/render.py +7 -5
- stacktrace_cli/daemon/__init__.py +1 -0
- stacktrace_cli/daemon/cli.py +106 -0
- stacktrace_cli/daemon/client.py +134 -0
- stacktrace_cli/daemon/composition.py +133 -0
- stacktrace_cli/daemon/observer.py +173 -0
- stacktrace_cli/daemon/paths.py +35 -0
- stacktrace_cli/daemon/protocol.py +29 -0
- stacktrace_cli/daemon/runtime.py +222 -0
- stacktrace_cli/daemon/server.py +164 -0
- stacktrace_cli/daemon/service.py +154 -0
- stacktrace_cli/daemon/store.py +368 -0
- stacktrace_cli/detector/blocked.py +450 -0
- stacktrace_cli/detector/cache.py +245 -9
- stacktrace_cli/detector/deterministic.py +359 -511
- stacktrace_cli/detector/finding.py +163 -15
- stacktrace_cli/detector/history.py +210 -0
- stacktrace_cli/detector/jev.py +213 -0
- stacktrace_cli/detector/priors.py +53 -285
- stacktrace_cli/detector/prompts/__init__.py +20 -0
- stacktrace_cli/detector/prompts/jev-v1/stacktrace-deceptive-completion.json +16 -0
- stacktrace_cli/detector/prompts/jev-v2/stacktrace-deceptive-completion.json +16 -0
- stacktrace_cli/detector/reasoning.py +858 -308
- stacktrace_cli/detector/render.py +88 -7
- stacktrace_cli/detector/rules.py +69 -56
- stacktrace_cli/detector/run.py +423 -77
- stacktrace_cli/detector/secrets.py +8 -6
- stacktrace_cli/detector/windows.py +293 -0
- stacktrace_cli/monitor/reasoning.py +153 -21
- stacktrace_cli/monitor/render.py +421 -10
- stacktrace_cli/monitor/server.py +242 -21
- stacktrace_cli/monitor/site/app.js +1154 -178
- stacktrace_cli/monitor/site/index.html +16 -1
- stacktrace_cli/monitor/site/styles.css +89 -1
- stacktrace_cli/monitor/state.py +27 -1
- stacktrace_cli/monitor/watch.py +227 -47
- stacktrace_cli/options.py +107 -0
- stacktrace_cli/private_state.py +38 -0
- stacktrace_cli/remote/cli.py +20 -10
- stacktrace_cli/remote/detect_payload.py +37 -5
- stacktrace_cli/remote/redact.py +3 -3
- stacktrace_cli/remote/spool.py +3 -2
- stacktrace_cli/remote/sync_detect.py +15 -1
- stacktrace_cli/remote/upload_contract.py +7 -0
- stacktrace_cli/sessions/access.py +24 -0
- stacktrace_cli/sessions/index.py +30 -0
- stacktrace_cli/sessions/protocols.py +13 -0
- stacktrace_cli/sessions/render.py +15 -9
- stacktrace_cli/telemetry/__init__.py +11 -0
- stacktrace_cli/telemetry/cli.py +67 -0
- stacktrace_cli/telemetry/events.py +266 -0
- stacktrace_cli/telemetry/posthog.py +126 -0
- stacktrace_cli/telemetry/state.py +190 -0
- {stacktrace_cli-0.3.0.dist-info → stacktrace_cli-0.4.0.dist-info}/METADATA +56 -24
- stacktrace_cli-0.4.0.dist-info/RECORD +90 -0
- {stacktrace_cli-0.3.0.dist-info → stacktrace_cli-0.4.0.dist-info}/WHEEL +1 -1
- stacktrace_cli/detector/markers.py +0 -158
- stacktrace_cli/detector/prompts/v1/stacktrace-injected-instruction-followed.md +0 -14
- stacktrace_cli/detector/prompts/v1/stacktrace-intent-drift.md +0 -11
- stacktrace_cli-0.3.0.dist-info/RECORD +0 -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
|
-
|
|
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
|
-
|
|
230
|
-
"
|
|
231
|
-
"session-derived off this machine,
|
|
232
|
-
"
|
|
233
|
-
"--
|
|
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=
|
|
290
|
+
type=click.IntRange(min=0),
|
|
243
291
|
default=DEFAULT_BUDGET,
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
outbound call and
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
"the
|
|
377
|
-
"
|
|
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=
|
|
382
|
-
default=
|
|
383
|
-
|
|
384
|
-
help=
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
`
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
] =
|
|
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
|
|
403
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
|
593
|
-
#
|
|
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
|
+
)
|