stacktrace-cli 0.7.1__py3-none-any.whl → 0.8.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/cli.py +60 -16
- stacktrace_cli/correlate/observed.py +47 -21
- stacktrace_cli/daemon/cli.py +12 -0
- stacktrace_cli/daemon/fleet.py +5 -2
- stacktrace_cli/daemon/jobs.py +6 -0
- stacktrace_cli/daemon/runtime.py +15 -3
- stacktrace_cli/daemon/store.py +12 -0
- stacktrace_cli/daemon/webhook.py +3 -13
- stacktrace_cli/detector/analyzer.py +1 -3
- stacktrace_cli/detector/deterministic.py +440 -82
- stacktrace_cli/detector/finding.py +0 -15
- stacktrace_cli/detector/prompts/jev-v2/stacktrace-infra-unsafe-action.json +10 -0
- stacktrace_cli/detector/reasoning.py +133 -16
- stacktrace_cli/detector/run.py +7 -5
- stacktrace_cli/detector/windows.py +150 -0
- stacktrace_cli/monitor/render.py +2 -2
- stacktrace_cli/monitor/site/styles.css +4 -303
- stacktrace_cli/remote/policy_service.py +16 -1
- stacktrace_cli/remote/redact.py +0 -113
- stacktrace_cli/telemetry/__init__.py +4 -3
- stacktrace_cli/telemetry/cli.py +13 -2
- stacktrace_cli/telemetry/events.py +256 -16
- stacktrace_cli/telemetry/posthog.py +11 -7
- stacktrace_cli/telemetry/state.py +32 -3
- {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.8.0.dist-info}/METADATA +28 -13
- {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.8.0.dist-info}/RECORD +29 -28
- {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.8.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.8.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
pass-through
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,9 +406,10 @@ def detect(
|
|
|
365
406
|
) -> None:
|
|
366
407
|
"""Find security and reliability findings in what agents did.
|
|
367
408
|
|
|
368
|
-
|
|
369
|
-
outbound call
|
|
370
|
-
|
|
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
414
|
and a leaked credential cannot share one (ADR-0010).
|
|
373
415
|
|
|
@@ -378,7 +420,9 @@ def detect(
|
|
|
378
420
|
"On a default run" is the whole of the qualification, and it is here rather
|
|
379
421
|
than left to the flags: `--reasoning --analyzer jev` adds a third stage
|
|
380
422
|
that sends per-call windows -- a completion claim, one verification command
|
|
381
|
-
and its output
|
|
423
|
+
and its output; and, for a mutating infrastructure command, that command
|
|
424
|
+
with the user's own requests in the session -- to TypeSafe's Jev
|
|
425
|
+
(ADR-0033). Both flags must be typed,
|
|
382
426
|
neither has a default, and this output names them when a session is left
|
|
383
427
|
unjudged, so it is a run a reader can ask for and therefore one this
|
|
384
428
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1918
|
+
return kept
|
stacktrace_cli/daemon/cli.py
CHANGED
|
@@ -14,6 +14,7 @@ from typing import TypeVar
|
|
|
14
14
|
|
|
15
15
|
import click
|
|
16
16
|
|
|
17
|
+
from ..telemetry.events import ensure_internal_saved, follow_saved_internal_only
|
|
17
18
|
from .config import DaemonConfig, parse_reasoning
|
|
18
19
|
from .paths import RuntimePaths
|
|
19
20
|
from .presentation import render_finding
|
|
@@ -145,6 +146,14 @@ def run_command(
|
|
|
145
146
|
reasoning: bool,
|
|
146
147
|
) -> None:
|
|
147
148
|
"""Run the daemon in the foreground for a host supervisor."""
|
|
149
|
+
# This process outlives the shell that launched it, so it follows the
|
|
150
|
+
# saved mark only, and a later `STACKTRACE_INTERNAL=0` takes effect
|
|
151
|
+
# without a restart (ADR-0091). Save the launch value first, and say so
|
|
152
|
+
# in the daemon log if that fails.
|
|
153
|
+
warning = ensure_internal_saved()
|
|
154
|
+
if warning is not None:
|
|
155
|
+
click.echo(f"stacktrace: {warning}", err=True)
|
|
156
|
+
follow_saved_internal_only()
|
|
148
157
|
_, _, run_daemon = _runtime()
|
|
149
158
|
paths = RuntimePaths.from_environment()
|
|
150
159
|
ctx = click.get_current_context()
|
|
@@ -432,6 +441,9 @@ def start(reasoning: bool) -> None:
|
|
|
432
441
|
os.environ.pop(_JEV_KEY_ENV, None)
|
|
433
442
|
os.environ["STACKTRACE_REASONING"] = "false"
|
|
434
443
|
|
|
444
|
+
warning = ensure_internal_saved()
|
|
445
|
+
if warning is not None:
|
|
446
|
+
click.echo(f"stacktrace: {warning}", err=True)
|
|
435
447
|
paths = RuntimePaths.from_environment()
|
|
436
448
|
click.echo("Starting Stacktrace daemon...", err=True)
|
|
437
449
|
try:
|
stacktrace_cli/daemon/fleet.py
CHANGED
|
@@ -56,7 +56,7 @@ from stacktrace_cli.remote.upload_contract import (
|
|
|
56
56
|
)
|
|
57
57
|
from stacktrace_cli.telemetry import emit_derived
|
|
58
58
|
|
|
59
|
-
from .store import FINDING, FindingStore, QueuedRecord
|
|
59
|
+
from .store import FINDING, FindingStore, QueuedRecord, native_session_id
|
|
60
60
|
|
|
61
61
|
JsonObject = dict[str, Any]
|
|
62
62
|
|
|
@@ -622,7 +622,10 @@ class FleetSubscriber:
|
|
|
622
622
|
"severity": str(payload.get("severity")),
|
|
623
623
|
"sink": "fleet",
|
|
624
624
|
"agent_kind": kind,
|
|
625
|
-
"session_id":
|
|
625
|
+
"session_id": native_session_id(
|
|
626
|
+
kind,
|
|
627
|
+
str((payload.get("session") or {}).get("session_id", "")), # type: ignore[union-attr]
|
|
628
|
+
),
|
|
626
629
|
},
|
|
627
630
|
)
|
|
628
631
|
|
stacktrace_cli/daemon/jobs.py
CHANGED
|
@@ -184,6 +184,12 @@ class Job:
|
|
|
184
184
|
# catch here for a value that should never reach a `Job` at
|
|
185
185
|
# all.
|
|
186
186
|
periods = max(1, math.floor((after - original_due_at) / self.interval) + 1)
|
|
187
|
+
# That holds for real numbers, not floats: `(due + 200) - due`
|
|
188
|
+
# can round to `199.99999999999997`, so `floor` comes out one
|
|
189
|
+
# short and the deadline lands exactly on `after`. It is
|
|
190
|
+
# never more than one short, so one extra period is enough.
|
|
191
|
+
if original_due_at + periods * self.interval <= after:
|
|
192
|
+
periods += 1
|
|
187
193
|
self._due_at = original_due_at + periods * self.interval
|
|
188
194
|
return True
|
|
189
195
|
|
stacktrace_cli/daemon/runtime.py
CHANGED
|
@@ -595,8 +595,20 @@ def _log_webhook(report: WebhookReport) -> None:
|
|
|
595
595
|
logger.info("webhook drain: %s", detail)
|
|
596
596
|
|
|
597
597
|
|
|
598
|
+
#: The component each job name reports its errors under (ADR-0091). A name
|
|
599
|
+
#: not here is `daemon`, which still says it was a scheduled job.
|
|
600
|
+
_JOB_COMPONENTS = {
|
|
601
|
+
"fleet": "fleet",
|
|
602
|
+
"flush": "fleet",
|
|
603
|
+
"webhook": "webhook",
|
|
604
|
+
"bom": "bom",
|
|
605
|
+
"sync-bom": "bom",
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
|
|
598
609
|
def _log_job_error(name: str, error: Exception) -> None:
|
|
599
|
-
|
|
610
|
+
# `fleet:<kind>` is the Fleet drain for one agent kind.
|
|
611
|
+
emit_error(error, _JOB_COMPONENTS.get(name.split(":", 1)[0], "daemon"))
|
|
600
612
|
# The class name only, never `str(error)`: an exception's message can
|
|
601
613
|
# carry a local path, transcript-derived text, or a server response body,
|
|
602
614
|
# all of which the activity log's content-free contract forbids.
|
|
@@ -647,7 +659,7 @@ def _log_error(session: SessionKey, error: Exception) -> None:
|
|
|
647
659
|
# Dated by the handler's own formatter, because `daemon status` reports
|
|
648
660
|
# the last one and "there was an error" without "when" cannot distinguish
|
|
649
661
|
# a repeating failure from one that happened once, weeks ago.
|
|
650
|
-
emit_error(error)
|
|
662
|
+
emit_error(error, "observer")
|
|
651
663
|
# `%r`, not `%s`: `session_id` is peer-supplied, and an embedded newline
|
|
652
664
|
# under `%s` would let it forge its own fake log line.
|
|
653
665
|
logger.warning(
|
|
@@ -684,7 +696,7 @@ def _log_composition_error(work: tuple[Scope, ...], error: Exception) -> None:
|
|
|
684
696
|
"""Every call here is a failure (unlike `_log_drain`/`_log_bom`, which
|
|
685
697
|
also log a quiet success), so the marker is unconditional — a build that
|
|
686
698
|
keeps failing must be visible to `daemon status`'s marker-only read."""
|
|
687
|
-
emit_error(error)
|
|
699
|
+
emit_error(error, "composition")
|
|
688
700
|
# Never the path itself: `project` is a local filesystem path, the same
|
|
689
701
|
# kind of content the activity log's contract forbids for an exception's
|
|
690
702
|
# message. "project"/"user" keeps the shape (which scopes failed, and
|
stacktrace_cli/daemon/store.py
CHANGED
|
@@ -117,6 +117,18 @@ class Notification:
|
|
|
117
117
|
body: str
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
def native_session_id(agent_kind: str, qualified: str) -> str:
|
|
121
|
+
"""The stored finding's `session_id` is `f"{agent_kind}:{native_id}"`
|
|
122
|
+
(`FindingStore._validate_session`). ADR-0067's payload allowlist is agent
|
|
123
|
+
kind and *native* session ID as separate fields, and telemetry's own
|
|
124
|
+
`session_id` domain is a bare UUID (`telemetry/events.py`), so the
|
|
125
|
+
qualifier must come off before either one sees it. Every subscriber that
|
|
126
|
+
reports a delivery reads it through here: Fleet's did not, and sent the
|
|
127
|
+
qualified form, which telemetry refused as `telemetry_failed` (ADR-0091)."""
|
|
128
|
+
prefix = f"{agent_kind}:"
|
|
129
|
+
return qualified.removeprefix(prefix) if qualified.startswith(prefix) else qualified
|
|
130
|
+
|
|
131
|
+
|
|
120
132
|
class FindingStore:
|
|
121
133
|
"""Persist findings and at-least-once delivery state per exact session."""
|
|
122
134
|
|
stacktrace_cli/daemon/webhook.py
CHANGED
|
@@ -19,7 +19,7 @@ from stacktrace_cli.detector.secrets import find_secrets
|
|
|
19
19
|
from stacktrace_cli.telemetry import emit_derived
|
|
20
20
|
from stacktrace_cli.webhook.config import DEFAULT_FORMAT, ConfigError, load_webhook_config
|
|
21
21
|
|
|
22
|
-
from .store import FindingStore, QueuedRecord
|
|
22
|
+
from .store import FindingStore, QueuedRecord, native_session_id
|
|
23
23
|
|
|
24
24
|
WEBHOOK_SUBSCRIBER = "webhook"
|
|
25
25
|
|
|
@@ -223,16 +223,6 @@ def _stop_reason(error: Exception) -> str:
|
|
|
223
223
|
return f"delivery failed: {detail}"
|
|
224
224
|
|
|
225
225
|
|
|
226
|
-
def _native_session_id(agent_kind: str, qualified: str) -> str:
|
|
227
|
-
"""The stored finding's `session_id` is `f"{agent_kind}:{native_id}"`
|
|
228
|
-
(`FindingStore._validate_session`). ADR-0067's payload allowlist is agent
|
|
229
|
-
kind and *native* session ID as separate fields, and telemetry's own
|
|
230
|
-
`session_id` domain is a bare UUID (`telemetry/events.py`), so the
|
|
231
|
-
qualifier must come off before either one sees it."""
|
|
232
|
-
prefix = f"{agent_kind}:"
|
|
233
|
-
return qualified.removeprefix(prefix) if qualified.startswith(prefix) else qualified
|
|
234
|
-
|
|
235
|
-
|
|
236
226
|
def _payload(agent_kind: str, row: QueuedRecord) -> dict[str, object]:
|
|
237
227
|
document = row.payload
|
|
238
228
|
session = document.get("session")
|
|
@@ -253,7 +243,7 @@ def _payload(agent_kind: str, row: QueuedRecord) -> dict[str, object]:
|
|
|
253
243
|
"confidence": confidence,
|
|
254
244
|
"session": {
|
|
255
245
|
"agent_kind": agent_kind,
|
|
256
|
-
"session_id":
|
|
246
|
+
"session_id": native_session_id(agent_kind, session_id),
|
|
257
247
|
},
|
|
258
248
|
}
|
|
259
249
|
# Additive (ADR-0073), and deliberately not required: a row stored before
|
|
@@ -534,6 +524,6 @@ def _report_delivered(agent_kind: str, row: QueuedRecord) -> None:
|
|
|
534
524
|
"severity": str(payload.get("severity")),
|
|
535
525
|
"sink": "webhook",
|
|
536
526
|
"agent_kind": agent_kind,
|
|
537
|
-
"session_id":
|
|
527
|
+
"session_id": native_session_id(agent_kind, str(session_id)),
|
|
538
528
|
},
|
|
539
529
|
)
|
|
@@ -39,7 +39,7 @@ import os
|
|
|
39
39
|
import shutil
|
|
40
40
|
import subprocess
|
|
41
41
|
from dataclasses import dataclass, field
|
|
42
|
-
from typing import
|
|
42
|
+
from typing import Protocol, runtime_checkable
|
|
43
43
|
|
|
44
44
|
from stacktrace_cli.detector.finding import UnknownReason
|
|
45
45
|
|
|
@@ -203,5 +203,3 @@ class ClaudeCodeAnalyzer:
|
|
|
203
203
|
#: A kind absent here yields `analyzer_unavailable`, never a fallback to another
|
|
204
204
|
#: vendor's CLI.
|
|
205
205
|
ANALYZERS: dict[str, Analyzer] = {"claude-code": ClaudeCodeAnalyzer()}
|
|
206
|
-
|
|
207
|
-
Stage3Reason = Literal["refusal", "timeout", "malformed_verdict", "analyzer_error"]
|