stacktrace-cli 0.5.2__py3-none-any.whl → 0.6.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 +98 -34
- stacktrace_cli/cloud/__init__.py +10 -0
- stacktrace_cli/cloud/setup.sh +51 -11
- stacktrace_cli/correlate/observed.py +62 -6
- stacktrace_cli/daemon/cli.py +113 -4
- stacktrace_cli/daemon/config.py +85 -5
- stacktrace_cli/daemon/jobs.py +48 -1
- stacktrace_cli/daemon/observer.py +31 -2
- stacktrace_cli/daemon/presentation.py +28 -0
- stacktrace_cli/daemon/runtime.py +46 -18
- stacktrace_cli/detector/deterministic.py +1859 -20
- stacktrace_cli/detector/render.py +1 -4
- stacktrace_cli/detector/rules.py +27 -4
- stacktrace_cli/detector/run.py +9 -1
- stacktrace_cli/monitor/reasoning.py +8 -6
- stacktrace_cli/monitor/server.py +4 -16
- stacktrace_cli/monitor/site/app.js +18 -291
- stacktrace_cli/monitor/site/index.html +0 -12
- stacktrace_cli/monitor/site/styles.css +0 -35
- stacktrace_cli/monitor/watch.py +176 -66
- stacktrace_cli/remote/identity.py +41 -12
- {stacktrace_cli-0.5.2.dist-info → stacktrace_cli-0.6.0.dist-info}/METADATA +3 -3
- {stacktrace_cli-0.5.2.dist-info → stacktrace_cli-0.6.0.dist-info}/RECORD +26 -26
- {stacktrace_cli-0.5.2.dist-info → stacktrace_cli-0.6.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.5.2.dist-info → stacktrace_cli-0.6.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/analysis.py
CHANGED
|
@@ -204,7 +204,7 @@ def analyse(
|
|
|
204
204
|
root=root,
|
|
205
205
|
session_ids=session_ids,
|
|
206
206
|
)
|
|
207
|
-
|
|
207
|
+
found = _analyse_sessions(
|
|
208
208
|
sessions,
|
|
209
209
|
window_start=window_start,
|
|
210
210
|
window_end=window_end,
|
|
@@ -220,6 +220,7 @@ def analyse(
|
|
|
220
220
|
jev_key=jev_key,
|
|
221
221
|
history=history,
|
|
222
222
|
)
|
|
223
|
+
return scoped_to_window(found)
|
|
223
224
|
|
|
224
225
|
|
|
225
226
|
def analyse_sessions(
|
|
@@ -347,30 +348,63 @@ def _acquire(
|
|
|
347
348
|
)
|
|
348
349
|
|
|
349
350
|
|
|
350
|
-
def
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
351
|
+
def _reaches(detection: Any, window_start: datetime, window_end: datetime) -> bool:
|
|
352
|
+
"""Whether this finding's own span overlaps the window.
|
|
353
|
+
|
|
354
|
+
Both ends are tested, because both can be crossed. `occurred_end` against
|
|
355
|
+
the start, so a rule firing across a run of turns belongs in the window
|
|
356
|
+
its last citation reaches; `occurred_start` against the end, so a finding
|
|
357
|
+
straddling the boundary is still reported rather than dropped for
|
|
358
|
+
finishing late.
|
|
359
|
+
|
|
360
|
+
The upper bound is not ceremony. `analyse` takes `window_end` *before*
|
|
361
|
+
collection -- deliberately, so a window cannot claim to cover a session
|
|
362
|
+
that started while the pipeline ran -- and collection then reads files
|
|
363
|
+
that may have been appended to in between. A finding can therefore carry
|
|
364
|
+
a clock later than the end the caller is shown, and reporting it would
|
|
365
|
+
contradict the heading the window prints.
|
|
366
|
+
|
|
367
|
+
A finding with no clock, or one whose clock is naive and cannot be
|
|
368
|
+
compared against an aware bound, is reported rather than judged: hiding a
|
|
369
|
+
real finding over a missing field is the more expensive mistake.
|
|
370
|
+
"""
|
|
371
|
+
end = detection.occurred_end or detection.occurred_start
|
|
372
|
+
start = detection.occurred_start or detection.occurred_end
|
|
373
|
+
if end is None or start is None or end.tzinfo is None or start.tzinfo is None:
|
|
374
|
+
return True
|
|
375
|
+
return end >= window_start and start <= window_end
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def scoped_to_window(analysis: Analysis) -> Analysis:
|
|
379
|
+
"""The same analysis, reporting only the findings the window reaches.
|
|
380
|
+
|
|
381
|
+
A window selects whole transcripts by file mtime, so a session written
|
|
382
|
+
inside it is collected entire and every rule walks all of its turns. A
|
|
383
|
+
long session still being typed into therefore carried its whole history
|
|
384
|
+
into whatever range the caller asked for: a 538-turn session last active a
|
|
385
|
+
minute ago put an 18-minute-old block in a five-minute window.
|
|
386
|
+
|
|
387
|
+
Only ever narrowing, and that is not a coincidence. A finding's clock
|
|
388
|
+
comes from the turns it cites, and a transcript's mtime is its last write,
|
|
389
|
+
so mtime is never earlier than `occurred_end`. A finding inside the window
|
|
390
|
+
therefore always sits in a file the window already admitted -- this can
|
|
391
|
+
drop what the mtime rule over-admitted, and can never need to recover
|
|
392
|
+
anything it excluded.
|
|
393
|
+
|
|
394
|
+
Applied by `analyse`, so `detect`, `monitor` and `sync detect` cannot
|
|
395
|
+
disagree about one `--since` (`cli.py` states that invariant). Not by
|
|
396
|
+
`analyse_sessions`: the daemon passes a watermark rather than a range a
|
|
397
|
+
person chose, and its findings are the record that feeds the store, the
|
|
398
|
+
webhook and Fleet, where a window has no business narrowing anything.
|
|
399
|
+
"""
|
|
400
|
+
kept = tuple(
|
|
401
|
+
d
|
|
402
|
+
for d in analysis.run.detections
|
|
403
|
+
if _reaches(d, analysis.window_start, analysis.window_end)
|
|
373
404
|
)
|
|
405
|
+
if len(kept) == len(analysis.run.detections):
|
|
406
|
+
return analysis
|
|
407
|
+
return replace(analysis, run=replace(analysis.run, detections=kept))
|
|
374
408
|
|
|
375
409
|
|
|
376
410
|
def _unplaced(sessions: SessionView, *, window_start: datetime, window_end: datetime) -> Analysis:
|
|
@@ -422,6 +456,7 @@ def analyse_progressively(
|
|
|
422
456
|
history: DriftHistory | None = None,
|
|
423
457
|
reasoning: bool = False,
|
|
424
458
|
budget: int = DEFAULT_BUDGET,
|
|
459
|
+
exclude_from_reasoning: frozenset[tuple[str, str]] = frozenset(),
|
|
425
460
|
) -> Iterator[Analysis]:
|
|
426
461
|
"""The same pipeline, delivered in instalments.
|
|
427
462
|
|
|
@@ -498,11 +533,13 @@ def analyse_progressively(
|
|
|
498
533
|
stage carrying fewer sessions than the preview did would make the count
|
|
499
534
|
on the page go backwards.
|
|
500
535
|
"""
|
|
501
|
-
return
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
536
|
+
return scoped_to_window(
|
|
537
|
+
Analysis(
|
|
538
|
+
run=run,
|
|
539
|
+
view=replace(view, sessions=sessions),
|
|
540
|
+
window_start=window_start,
|
|
541
|
+
window_end=window_end,
|
|
542
|
+
)
|
|
506
543
|
)
|
|
507
544
|
|
|
508
545
|
# The detector derives these from the view, and a stage that has not judged
|
|
@@ -572,12 +609,20 @@ def analyse_progressively(
|
|
|
572
609
|
if not reasoning or not ordered:
|
|
573
610
|
return
|
|
574
611
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
612
|
+
excluded_count = 0
|
|
613
|
+
if exclude_from_reasoning:
|
|
614
|
+
kept = tuple(
|
|
615
|
+
s
|
|
616
|
+
for s in view.sessions
|
|
617
|
+
if (s.session.session_id, s.session.agent_kind) not in exclude_from_reasoning
|
|
618
|
+
)
|
|
619
|
+
excluded_count = len(view.sessions) - len(kept)
|
|
620
|
+
reasoning_view = replace(view, sessions=kept) if excluded_count else view
|
|
621
|
+
else:
|
|
622
|
+
reasoning_view = view
|
|
623
|
+
|
|
579
624
|
judged = run_detector(
|
|
580
|
-
|
|
625
|
+
reasoning_view,
|
|
581
626
|
reasoning=True,
|
|
582
627
|
budget=budget,
|
|
583
628
|
cache=cache,
|
|
@@ -585,4 +630,23 @@ def analyse_progressively(
|
|
|
585
630
|
jev_key=jev_key,
|
|
586
631
|
history=history,
|
|
587
632
|
)
|
|
633
|
+
|
|
634
|
+
if excluded_count:
|
|
635
|
+
extra_detections = tuple(
|
|
636
|
+
d
|
|
637
|
+
for d in accumulated.detections
|
|
638
|
+
if (d.session.session_id, d.session.agent_kind) in exclude_from_reasoning
|
|
639
|
+
)
|
|
640
|
+
extra_unknowns = tuple(
|
|
641
|
+
u
|
|
642
|
+
for u in accumulated.unknowns
|
|
643
|
+
if (u.session.session_id, u.session.agent_kind) in exclude_from_reasoning
|
|
644
|
+
)
|
|
645
|
+
judged = replace(
|
|
646
|
+
judged,
|
|
647
|
+
detections=judged.detections + extra_detections,
|
|
648
|
+
unknowns=judged.unknowns + extra_unknowns,
|
|
649
|
+
sessions=judged.sessions + excluded_count,
|
|
650
|
+
)
|
|
651
|
+
|
|
588
652
|
yield _stage(judged, tuple(ordered))
|
stacktrace_cli/cloud/__init__.py
CHANGED
|
@@ -12,6 +12,16 @@ CLI and runs this:
|
|
|
12
12
|
uv tool install stacktrace-cli
|
|
13
13
|
stacktrace-cloud-setup
|
|
14
14
|
|
|
15
|
+
Or, equivalently, one hosted line that wraps exactly those two steps (see
|
|
16
|
+
`stacktrace-site`'s `public/remote-install.sh`). Download before running rather
|
|
17
|
+
than piping directly into `sh`: without `pipefail` (the default in the `sh`
|
|
18
|
+
that a setup-script box runs), a failed `curl` still lets `sh` read an empty
|
|
19
|
+
script and exit 0, so a broken download would otherwise snapshot an
|
|
20
|
+
environment with neither the CLI nor the boot hook and report success:
|
|
21
|
+
|
|
22
|
+
curl -fsSL https://stacktrace.ai/remote-install.sh -o /tmp/remote-install.sh \
|
|
23
|
+
&& sh /tmp/remote-install.sh
|
|
24
|
+
|
|
15
25
|
Both variables matter: setup runs as root, and `uv tool install`'s root
|
|
16
26
|
defaults put the tool's environment under `/root/.local/share/uv/tools` and the
|
|
17
27
|
linked executable under `/root/.local/bin` — both inside `/root`, which a later
|
stacktrace_cli/cloud/setup.sh
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
# every home so it fires whichever user the session runs as.
|
|
11
11
|
# 2. The asset identity, in every home's ~/.config/stacktrace/asset-id, so
|
|
12
12
|
# `stacktrace remote sync` resolves it at the persisted rung (ADR-0065)
|
|
13
|
-
# instead of minting a fresh asset per session.
|
|
13
|
+
# instead of minting a fresh asset per session. Beside it,
|
|
14
|
+
# asset-display-prefix makes the fleet label `cloud (<id>)` rather than
|
|
15
|
+
# the image's hostname (`vm`), which is the same on every box. Label
|
|
16
|
+
# only: the key above is what identifies the asset.
|
|
14
17
|
#
|
|
15
18
|
# The telemetry install id is deliberately NOT seeded here. Seeding it would
|
|
16
19
|
# make `read_install_id()` non-None before any event runs, so the one-time
|
|
@@ -39,9 +42,14 @@
|
|
|
39
42
|
# from STACKTRACE_ASSET_EXTERNAL_ID in the environment
|
|
40
43
|
# --root DIR treat DIR as / (for testing)
|
|
41
44
|
#
|
|
42
|
-
# The remote token and API URL are read by the boot
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
+
# The remote token and API URL, and the Slack webhook URL, are read by the boot
|
|
46
|
+
# hook at session time from STACKTRACE_REMOTE_TOKEN, STACKTRACE_REMOTE_API_URL
|
|
47
|
+
# and STACKTRACE_SLACK_WEBHOOK_URL, not here: they belong to the environment's
|
|
48
|
+
# variables, not to the snapshot this seeds. The webhook in particular must be
|
|
49
|
+
# configured by the boot hook and not by this script, because
|
|
50
|
+
# `webhook configure` writes `$HOME/.config/stacktrace/webhook.toml` and this
|
|
51
|
+
# script runs as root — a webhook configured here would land in /root and the
|
|
52
|
+
# daemon, running as the session user, would read a home that has none.
|
|
45
53
|
set -uo pipefail
|
|
46
54
|
|
|
47
55
|
ASSET_ID="${STACKTRACE_ASSET_EXTERNAL_ID:-}"
|
|
@@ -54,7 +62,7 @@ while [ $# -gt 0 ]; do
|
|
|
54
62
|
# the loop spins forever — a malformed invocation would hang setup.
|
|
55
63
|
--asset-id) [ $# -ge 2 ] || { echo "✗ --asset-id needs a value" >&2; exit 2; }; ASSET_ID="$2"; shift 2 ;;
|
|
56
64
|
--root) [ $# -ge 2 ] || { echo "✗ --root needs a value" >&2; exit 2; }; ROOT="$2"; shift 2 ;;
|
|
57
|
-
-h|--help) sed -n '2,
|
|
65
|
+
-h|--help) sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
58
66
|
*) echo "✗ unknown argument: $1" >&2; exit 2 ;;
|
|
59
67
|
esac
|
|
60
68
|
done
|
|
@@ -73,6 +81,7 @@ _uuid() {
|
|
|
73
81
|
fi
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
DISPLAY_PREFIX="cloud"
|
|
76
85
|
[ -n "$ASSET_ID" ] || ASSET_ID="cloud-$(_uuid)"
|
|
77
86
|
# remote/identity.py:_is_identifier rejects whitespace, non-printable
|
|
78
87
|
# characters, and anything over 255 characters. A value this accepts but the
|
|
@@ -108,19 +117,45 @@ cat > "$BOOT" <<'BOOTSCRIPT' || { echo "✗ cannot write $BOOT" >&2; exit 1; }
|
|
|
108
117
|
set -u
|
|
109
118
|
[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0
|
|
110
119
|
command -v stacktrace >/dev/null 2>&1 || exit 0
|
|
111
|
-
|
|
120
|
+
# A webhook is an independent subscriber, not a Fleet feature (ADR-0067): a
|
|
121
|
+
# session with only STACKTRACE_SLACK_WEBHOOK_URL set still needs the daemon
|
|
122
|
+
# running to drain it, so the remote token gates `remote configure` alone,
|
|
123
|
+
# never this whole hook.
|
|
124
|
+
if [ -z "${STACKTRACE_REMOTE_TOKEN:-}" ] && [ -z "${STACKTRACE_SLACK_WEBHOOK_URL:-}" ]; then
|
|
125
|
+
exit 0
|
|
126
|
+
fi
|
|
112
127
|
|
|
113
128
|
STATE="$HOME/.local/state/stacktrace"
|
|
114
129
|
mkdir -p "$STATE" && chmod 700 "$STATE"
|
|
115
130
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
if [ -n "${STACKTRACE_REMOTE_TOKEN:-}" ]; then
|
|
132
|
+
stacktrace remote configure \
|
|
133
|
+
--token "$STACKTRACE_REMOTE_TOKEN" \
|
|
134
|
+
--api-url "${STACKTRACE_REMOTE_API_URL:-https://api.stacktrace.ai}" >/dev/null 2>&1 || true
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# The Slack endpoint is session-time configuration like the token above, not
|
|
138
|
+
# part of the snapshot: written here it lands in the session user's home, which
|
|
139
|
+
# is the home the daemon started below reads `webhook.toml` from. Rotating the
|
|
140
|
+
# URL is then an environment-variable change, with no snapshot to rebuild.
|
|
141
|
+
# `--secret` is always passed explicitly, empty if unset: the option prompts
|
|
142
|
+
# when neither it nor STACKTRACE_WEBHOOK_SECRET is given, and a prompt in a hook
|
|
143
|
+
# with no terminal aborts the command. Configured before the daemon starts, so
|
|
144
|
+
# the daemon reads the endpoint at startup rather than waiting for a drain.
|
|
145
|
+
if [ -n "${STACKTRACE_SLACK_WEBHOOK_URL:-}" ]; then
|
|
146
|
+
stacktrace webhook configure \
|
|
147
|
+
--url "$STACKTRACE_SLACK_WEBHOOK_URL" \
|
|
148
|
+
--secret "${STACKTRACE_WEBHOOK_SECRET:-}" >/dev/null 2>&1 || true
|
|
149
|
+
fi
|
|
119
150
|
|
|
120
151
|
# Streams redirected because own_std_streams only re-points fd 1/2 on log
|
|
121
152
|
# rollover; without this the daemon inherits the hook's pipe to Claude Code.
|
|
153
|
+
# --no-reasoning is explicit, not just `run`'s own default (ADR-0081): this
|
|
154
|
+
# hook never provisions TYPESAFE_API_KEY itself, but a cloud VM's ambient
|
|
155
|
+
# environment is not something this script controls or can audit ahead of
|
|
156
|
+
# time, and this surface has nobody present to notice if that changed.
|
|
122
157
|
if ! stacktrace daemon status 2>/dev/null | grep -q '^running: yes'; then
|
|
123
|
-
setsid nohup stacktrace daemon run </dev/null >>"$STATE/daemon.log" 2>&1 &
|
|
158
|
+
setsid nohup stacktrace daemon run --no-reasoning </dev/null >>"$STATE/daemon.log" 2>&1 &
|
|
124
159
|
fi
|
|
125
160
|
exit 0
|
|
126
161
|
BOOTSCRIPT
|
|
@@ -149,6 +184,11 @@ for home in "${ROOT}"/root "${ROOT}"/home/*; do
|
|
|
149
184
|
fi
|
|
150
185
|
chmod 600 "$cfg/asset-id" 2>/dev/null || true
|
|
151
186
|
|
|
187
|
+
if ! printf '%s\n' "$DISPLAY_PREFIX" > "$cfg/asset-display-prefix" 2>/dev/null; then
|
|
188
|
+
echo "✗ cannot write $cfg/asset-display-prefix — not seeding $home" >&2; failed=1; continue
|
|
189
|
+
fi
|
|
190
|
+
chmod 600 "$cfg/asset-display-prefix" 2>/dev/null || true
|
|
191
|
+
|
|
152
192
|
# Register the SessionStart hook, merging so any existing settings survive.
|
|
153
193
|
# The hook is the point of seeding a home — a home whose registration fails
|
|
154
194
|
# (an unwritable path, or a settings.json that is valid JSON of an unexpected
|
|
@@ -221,7 +261,7 @@ PY
|
|
|
221
261
|
for d in "$home/.config" "$cfg" "$home/.claude"; do
|
|
222
262
|
[ -e "$d" ] && chown --reference="$home" "$d" 2>/dev/null || true
|
|
223
263
|
done
|
|
224
|
-
chown --reference="$home" "$cfg/asset-id" \
|
|
264
|
+
chown --reference="$home" "$cfg/asset-id" "$cfg/asset-display-prefix" \
|
|
225
265
|
"$home/.claude/settings.json" 2>/dev/null || true
|
|
226
266
|
|
|
227
267
|
echo "✓ seeded $home"
|
|
@@ -1381,9 +1381,13 @@ _SEPARATORS = ("&&", "||", ";", "|", "\n")
|
|
|
1381
1381
|
_WORD_START_BEFORE_COMMENT = frozenset(" \t\n;&|<>()")
|
|
1382
1382
|
|
|
1383
1383
|
|
|
1384
|
-
def _split_on_separators(command: str) -> list[str]:
|
|
1384
|
+
def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
|
|
1385
1385
|
"""The commands in a shell line, split only where the shell would split it.
|
|
1386
1386
|
|
|
1387
|
+
Returns ``(segment_text, preceding_operator)`` pairs. The first segment's
|
|
1388
|
+
operator is ``None``; every later segment carries the ``&&``, ``||``, ``;``,
|
|
1389
|
+
``|`` or ``\\n`` that separated it from the one before it.
|
|
1390
|
+
|
|
1387
1391
|
A control operator is an operator **only unquoted and unescaped** — that is
|
|
1388
1392
|
the shell's own grammar, and it is verified against `bash` itself in
|
|
1389
1393
|
`tests/test_shell_separators_match_the_shell.py` rather than asserted here.
|
|
@@ -1420,10 +1424,11 @@ def _split_on_separators(command: str) -> list[str]:
|
|
|
1420
1424
|
left in, `shlex` hands the caller a bare newline where the next word should
|
|
1421
1425
|
be, and `git \\<newline> push` loses its subcommand to it.
|
|
1422
1426
|
"""
|
|
1423
|
-
parts: list[str] = []
|
|
1427
|
+
parts: list[tuple[str, str | None]] = []
|
|
1424
1428
|
current: list[str] = []
|
|
1425
1429
|
index = 0
|
|
1426
1430
|
quote: str | None = None
|
|
1431
|
+
preceding_op: str | None = None
|
|
1427
1432
|
#: Whether a `#` here would open a comment. True at the start of the string
|
|
1428
1433
|
#: and after anything that ends a word; false after any character a word is
|
|
1429
1434
|
#: made of, quotes included -- `echo "a"#b` prints `a#b`.
|
|
@@ -1475,11 +1480,20 @@ def _split_on_separators(command: str) -> list[str]:
|
|
|
1475
1480
|
index += 1
|
|
1476
1481
|
at_word_start = char in _WORD_START_BEFORE_COMMENT
|
|
1477
1482
|
continue
|
|
1478
|
-
|
|
1483
|
+
segment_text = "".join(current)
|
|
1484
|
+
if segment_text.strip():
|
|
1485
|
+
parts.append((segment_text, preceding_op))
|
|
1486
|
+
preceding_op = separator
|
|
1487
|
+
else:
|
|
1488
|
+
# Two separators with nothing between them (e.g. "&&\n"):
|
|
1489
|
+
# keep the conditional operator over a plain terminator so
|
|
1490
|
+
# the next real segment inherits the right dependency.
|
|
1491
|
+
if separator in ("&&", "||") or preceding_op is None:
|
|
1492
|
+
preceding_op = separator
|
|
1479
1493
|
current = []
|
|
1480
1494
|
index += len(separator)
|
|
1481
1495
|
at_word_start = True
|
|
1482
|
-
parts.append("".join(current))
|
|
1496
|
+
parts.append(("".join(current), preceding_op))
|
|
1483
1497
|
return parts
|
|
1484
1498
|
|
|
1485
1499
|
|
|
@@ -1521,7 +1535,7 @@ def _segments_of(text: str, depth: int) -> tuple[tuple[str, tuple[str, ...]], ..
|
|
|
1521
1535
|
`command_segments` would run the masks a second time on their own output.
|
|
1522
1536
|
"""
|
|
1523
1537
|
found: list[tuple[str, tuple[str, ...]]] = []
|
|
1524
|
-
for part in _split_on_separators(text):
|
|
1538
|
+
for part, _op in _split_on_separators(text):
|
|
1525
1539
|
# Comments are gone: `_split_on_separators` discards one at the word
|
|
1526
1540
|
# start that opens it, which is the only place the shell begins one.
|
|
1527
1541
|
# This used to skip a segment *beginning* with `#`, which caught the
|
|
@@ -1567,6 +1581,48 @@ def _segments_of(text: str, depth: int) -> tuple[tuple[str, tuple[str, ...]], ..
|
|
|
1567
1581
|
return tuple(found)
|
|
1568
1582
|
|
|
1569
1583
|
|
|
1584
|
+
def command_segments_with_ops(
|
|
1585
|
+
command: object, depth: int = 0
|
|
1586
|
+
) -> tuple[tuple[str, tuple[str, ...], str | None], ...]:
|
|
1587
|
+
"""Like `command_segments`, but each entry also carries the shell operator
|
|
1588
|
+
that preceded it (``None`` for the first segment, then ``&&``, ``||``,
|
|
1589
|
+
``;``, ``|`` or ``\\n``).
|
|
1590
|
+
|
|
1591
|
+
Only `_resolve_rm_targets` in the deterministic detector needs the operator
|
|
1592
|
+
context — to decide whether an inline ``cd`` reliably changed the directory
|
|
1593
|
+
and whether an ``rm`` segment is unconditionally reachable. Every other
|
|
1594
|
+
caller uses the plain `command_segments`, which discards the operator.
|
|
1595
|
+
"""
|
|
1596
|
+
if not isinstance(command, str) or not command:
|
|
1597
|
+
return ()
|
|
1598
|
+
text = _readable_commands(command, depth)
|
|
1599
|
+
found: list[tuple[str, tuple[str, ...], str | None]] = []
|
|
1600
|
+
for part, preceding_op in _split_on_separators(text):
|
|
1601
|
+
part = part.strip()
|
|
1602
|
+
if not part:
|
|
1603
|
+
continue
|
|
1604
|
+
try:
|
|
1605
|
+
tokens = shlex.split(part)
|
|
1606
|
+
except ValueError:
|
|
1607
|
+
continue
|
|
1608
|
+
while tokens and (_ASSIGNMENT.match(tokens[0]) or tokens[0] in _NOT_A_BINARY):
|
|
1609
|
+
if tokens.pop(0) == "time":
|
|
1610
|
+
while tokens and tokens[0] in _TIME_OPTIONS:
|
|
1611
|
+
tokens.pop(0)
|
|
1612
|
+
if not tokens:
|
|
1613
|
+
continue
|
|
1614
|
+
head = tokens[0].rsplit("/", 1)[-1]
|
|
1615
|
+
if not head or all(char in _OPERATOR_CHARS for char in head):
|
|
1616
|
+
continue
|
|
1617
|
+
arguments = tuple(tokens[1:])
|
|
1618
|
+
found.append((head, arguments, preceding_op))
|
|
1619
|
+
if head in _SHELLS and depth < _MAX_SHELL_DEPTH:
|
|
1620
|
+
body = _shell_command_body(arguments)
|
|
1621
|
+
if body is not None:
|
|
1622
|
+
found.extend(command_segments_with_ops(body, depth + 1))
|
|
1623
|
+
return tuple(found)
|
|
1624
|
+
|
|
1625
|
+
|
|
1570
1626
|
def redirect_targets(command: object) -> tuple[str, ...]:
|
|
1571
1627
|
"""Files a shell command redirects its output into.
|
|
1572
1628
|
|
|
@@ -1604,7 +1660,7 @@ def redirect_targets(command: object) -> tuple[str, ...]:
|
|
|
1604
1660
|
# body, an arithmetic comparison, a `case` frame or the body of a function
|
|
1605
1661
|
# nothing called is not a redirection, and each was named as a file before
|
|
1606
1662
|
# its mask existed.
|
|
1607
|
-
for part in _split_on_separators(_readable_commands(command, 0)):
|
|
1663
|
+
for part, _op in _split_on_separators(_readable_commands(command, 0)):
|
|
1608
1664
|
part = part.strip()
|
|
1609
1665
|
if not part:
|
|
1610
1666
|
continue
|
stacktrace_cli/daemon/cli.py
CHANGED
|
@@ -14,7 +14,7 @@ from typing import TypeVar
|
|
|
14
14
|
|
|
15
15
|
import click
|
|
16
16
|
|
|
17
|
-
from .config import DaemonConfig
|
|
17
|
+
from .config import DaemonConfig, parse_reasoning
|
|
18
18
|
from .paths import RuntimePaths
|
|
19
19
|
from .presentation import render_finding
|
|
20
20
|
from .store import FindingStore, SessionKey
|
|
@@ -117,6 +117,24 @@ def _retry_while_starting(
|
|
|
117
117
|
help="How often to upload endpoint composition. 0 disables it."
|
|
118
118
|
" Defaults to STACKTRACE_BOM_INTERVAL, then 6h.",
|
|
119
119
|
)
|
|
120
|
+
@click.option(
|
|
121
|
+
"--reasoning/--no-reasoning",
|
|
122
|
+
# Declared `False`, not `None`: `test_the_reasoning_stage_is_off_by_
|
|
123
|
+
# default_everywhere_it_can_be_asked_for` reads this literal value off
|
|
124
|
+
# every command in the tree, on the theory that a default legible only
|
|
125
|
+
# after tracing what `DaemonConfig.resolve` does with `None` is exactly
|
|
126
|
+
# the kind of default that drifts unnoticed (ADR-0029). Whether the flag
|
|
127
|
+
# was actually typed is recovered below via `get_parameter_source`, the
|
|
128
|
+
# same "flag beats environment beats default" ladder every other option
|
|
129
|
+
# here gets from `resolve` seeing `None` -- Click just won't hand this one
|
|
130
|
+
# a `None` to pass through.
|
|
131
|
+
default=False,
|
|
132
|
+
hidden=True,
|
|
133
|
+
help="Analyse changed sessions with a reasoning model (stage three). Off"
|
|
134
|
+
" by default: pass --reasoning to opt in, and set TYPESAFE_API_KEY so"
|
|
135
|
+
" there is something to answer with. Defaults to STACKTRACE_REASONING,"
|
|
136
|
+
" then false.",
|
|
137
|
+
)
|
|
120
138
|
def run_command(
|
|
121
139
|
scan_interval: str | None,
|
|
122
140
|
scan_since: str | None,
|
|
@@ -124,10 +142,17 @@ def run_command(
|
|
|
124
142
|
fleet_interval: str | None,
|
|
125
143
|
webhook_interval: str | None,
|
|
126
144
|
bom_interval: str | None,
|
|
145
|
+
reasoning: bool,
|
|
127
146
|
) -> None:
|
|
128
147
|
"""Run the daemon in the foreground for a host supervisor."""
|
|
129
148
|
_, _, run_daemon = _runtime()
|
|
130
149
|
paths = RuntimePaths.from_environment()
|
|
150
|
+
ctx = click.get_current_context()
|
|
151
|
+
reasoning_flag = (
|
|
152
|
+
reasoning
|
|
153
|
+
if ctx.get_parameter_source("reasoning") is click.core.ParameterSource.COMMANDLINE
|
|
154
|
+
else None
|
|
155
|
+
)
|
|
131
156
|
try:
|
|
132
157
|
config = DaemonConfig.resolve(
|
|
133
158
|
scan_interval=scan_interval,
|
|
@@ -136,17 +161,32 @@ def run_command(
|
|
|
136
161
|
fleet_interval=fleet_interval,
|
|
137
162
|
webhook_interval=webhook_interval,
|
|
138
163
|
bom_interval=bom_interval,
|
|
164
|
+
reasoning=reasoning_flag,
|
|
139
165
|
)
|
|
140
166
|
_reject_unobservable_kinds(config)
|
|
141
167
|
except ValueError as error:
|
|
142
168
|
raise click.ClickException(str(error)) from error
|
|
143
|
-
from .lifecycle import DETACHED_ENV
|
|
169
|
+
from .lifecycle import DETACHED_ENV, StartOutcome
|
|
170
|
+
|
|
171
|
+
detached = os.environ.get(DETACHED_ENV) == "1"
|
|
172
|
+
|
|
173
|
+
def report_ready() -> None:
|
|
174
|
+
if not detached:
|
|
175
|
+
click.echo(
|
|
176
|
+
"Stacktrace daemon is running in the foreground. Press Ctrl-C to stop.",
|
|
177
|
+
err=True,
|
|
178
|
+
)
|
|
144
179
|
|
|
145
180
|
status = run_daemon(
|
|
146
181
|
paths,
|
|
147
182
|
config,
|
|
148
|
-
own_std_streams=
|
|
183
|
+
own_std_streams=detached,
|
|
184
|
+
on_ready=report_ready,
|
|
149
185
|
)
|
|
186
|
+
if status is StartOutcome.ALREADY_RUNNING:
|
|
187
|
+
if not detached:
|
|
188
|
+
click.echo("Stacktrace daemon is already running.")
|
|
189
|
+
return
|
|
150
190
|
if status:
|
|
151
191
|
raise click.exceptions.Exit(status)
|
|
152
192
|
|
|
@@ -317,13 +357,79 @@ def _database_line(paths: RuntimePaths) -> str:
|
|
|
317
357
|
return f"{store.database_size()} bytes ({paths.state})"
|
|
318
358
|
|
|
319
359
|
|
|
360
|
+
_JEV_KEY_ENV = "TYPESAFE_API_KEY"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _ensure_jev_key() -> None:
|
|
364
|
+
"""Prompt for the jev key when it is not already in the environment.
|
|
365
|
+
|
|
366
|
+
Sets the key in ``os.environ`` so the forked daemon inherits it.
|
|
367
|
+
An empty response or a non-interactive terminal skips — reasoning
|
|
368
|
+
degrades gracefully when the analyzer reports unavailable.
|
|
369
|
+
"""
|
|
370
|
+
if os.environ.get(_JEV_KEY_ENV):
|
|
371
|
+
return
|
|
372
|
+
if not sys.stdin.isatty():
|
|
373
|
+
return
|
|
374
|
+
key = click.prompt(
|
|
375
|
+
"TYPESAFE_API_KEY for reasoning (Enter to skip)",
|
|
376
|
+
default="",
|
|
377
|
+
hide_input=True,
|
|
378
|
+
show_default=False,
|
|
379
|
+
)
|
|
380
|
+
if key:
|
|
381
|
+
os.environ[_JEV_KEY_ENV] = key
|
|
382
|
+
|
|
383
|
+
|
|
320
384
|
@daemon.command()
|
|
321
|
-
|
|
385
|
+
@click.option(
|
|
386
|
+
"--reasoning/--no-reasoning",
|
|
387
|
+
default=False,
|
|
388
|
+
hidden=True,
|
|
389
|
+
help="Analyse changed sessions with a reasoning model (stage three). Off"
|
|
390
|
+
" by default: pass --reasoning to opt in for this run, and set"
|
|
391
|
+
" TYPESAFE_API_KEY so there is something to answer with.",
|
|
392
|
+
)
|
|
393
|
+
def start(reasoning: bool) -> None:
|
|
322
394
|
"""Start one detached daemon when no host service manager is available."""
|
|
323
395
|
_runtime()
|
|
324
396
|
from .lifecycle import StartOutcome, start_daemon, transition_lock
|
|
325
397
|
|
|
398
|
+
# `start_daemon` forks/execs inheriting `os.environ` as it stands right
|
|
399
|
+
# here -- the same route `_ensure_jev_key` uses to hand the detached
|
|
400
|
+
# process a key it just prompted for, and now the only route
|
|
401
|
+
# `--reasoning`/`--no-reasoning` has to reach that process's own
|
|
402
|
+
# `DaemonConfig.resolve()` too, which reads `STACKTRACE_REASONING`. When
|
|
403
|
+
# the flag was not actually typed, the same "flag beats environment beats
|
|
404
|
+
# default" ladder `run_command` gets from `resolve()` applies here too:
|
|
405
|
+
# an operator who already exported `STACKTRACE_REASONING=true` before
|
|
406
|
+
# calling `start` (the documented way to opt in a detached daemon,
|
|
407
|
+
# config.py's own comment on `SETTINGS`) must not have that silently
|
|
408
|
+
# overwritten by this command's unrelated `False` click default.
|
|
409
|
+
ctx = click.get_current_context()
|
|
410
|
+
explicit = ctx.get_parameter_source("reasoning") is click.core.ParameterSource.COMMANDLINE
|
|
411
|
+
if explicit:
|
|
412
|
+
enabled = reasoning
|
|
413
|
+
else:
|
|
414
|
+
inherited = os.environ.get("STACKTRACE_REASONING")
|
|
415
|
+
try:
|
|
416
|
+
enabled = inherited is not None and parse_reasoning(inherited)
|
|
417
|
+
except ValueError as error:
|
|
418
|
+
raise click.ClickException(str(error)) from error
|
|
419
|
+
if enabled:
|
|
420
|
+
_ensure_jev_key()
|
|
421
|
+
os.environ["STACKTRACE_REASONING"] = "true"
|
|
422
|
+
else:
|
|
423
|
+
# An explicit --no-reasoning, or no opt-in at all: either way, do not
|
|
424
|
+
# let a key already present in the *caller's* environment (a shell
|
|
425
|
+
# profile, an exported var) reach the daemon and reason anyway --
|
|
426
|
+
# that is exactly the implicit activation this opt-in exists to
|
|
427
|
+
# prevent.
|
|
428
|
+
os.environ.pop(_JEV_KEY_ENV, None)
|
|
429
|
+
os.environ["STACKTRACE_REASONING"] = "false"
|
|
430
|
+
|
|
326
431
|
paths = RuntimePaths.from_environment()
|
|
432
|
+
click.echo("Starting Stacktrace daemon...", err=True)
|
|
327
433
|
try:
|
|
328
434
|
with transition_lock(paths.transition_lock):
|
|
329
435
|
outcome = start_daemon(paths)
|
|
@@ -342,6 +448,7 @@ def stop() -> None:
|
|
|
342
448
|
from .lifecycle import daemon_lock_held, transition_lock
|
|
343
449
|
|
|
344
450
|
paths = RuntimePaths.from_environment()
|
|
451
|
+
click.echo("Stopping Stacktrace daemon...", err=True)
|
|
345
452
|
with transition_lock(paths.transition_lock):
|
|
346
453
|
if not daemon_lock_held(paths.lock):
|
|
347
454
|
click.echo("Stacktrace daemon is not running.")
|
|
@@ -416,6 +523,7 @@ def flush() -> None:
|
|
|
416
523
|
"no daemon is running, so there is no queue to drain; this endpoint "
|
|
417
524
|
"uploads by scanning instead — run `stacktrace remote sync detect`"
|
|
418
525
|
)
|
|
526
|
+
click.echo("Flushing queued findings...", err=True)
|
|
419
527
|
try:
|
|
420
528
|
result = request_flush(paths.socket)
|
|
421
529
|
except (OSError, ProtocolError) as error:
|
|
@@ -451,6 +559,7 @@ def sync_bom() -> None:
|
|
|
451
559
|
raise click.ClickException(
|
|
452
560
|
"no daemon is running; to upload without one, run `stacktrace remote sync endpoint`"
|
|
453
561
|
)
|
|
562
|
+
click.echo("Syncing endpoint composition...", err=True)
|
|
454
563
|
try:
|
|
455
564
|
result = request_sync_bom(paths.socket)
|
|
456
565
|
except (OSError, ProtocolError) as error:
|