stacktrace-cli 0.5.1__py3-none-any.whl → 0.5.3__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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.5.1"
5
+ __version__ = "0.5.3"
6
6
 
7
7
  # Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
8
8
  # logged in any other process would reach Python's last-resort handler and
@@ -204,7 +204,7 @@ def analyse(
204
204
  root=root,
205
205
  session_ids=session_ids,
206
206
  )
207
- return _analyse_sessions(
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(
@@ -373,6 +374,65 @@ def _correlated(
373
374
  )
374
375
 
375
376
 
377
+ def _reaches(detection: Any, window_start: datetime, window_end: datetime) -> bool:
378
+ """Whether this finding's own span overlaps the window.
379
+
380
+ Both ends are tested, because both can be crossed. `occurred_end` against
381
+ the start, so a rule firing across a run of turns belongs in the window
382
+ its last citation reaches; `occurred_start` against the end, so a finding
383
+ straddling the boundary is still reported rather than dropped for
384
+ finishing late.
385
+
386
+ The upper bound is not ceremony. `analyse` takes `window_end` *before*
387
+ collection -- deliberately, so a window cannot claim to cover a session
388
+ that started while the pipeline ran -- and collection then reads files
389
+ that may have been appended to in between. A finding can therefore carry
390
+ a clock later than the end the caller is shown, and reporting it would
391
+ contradict the heading the window prints.
392
+
393
+ A finding with no clock, or one whose clock is naive and cannot be
394
+ compared against an aware bound, is reported rather than judged: hiding a
395
+ real finding over a missing field is the more expensive mistake.
396
+ """
397
+ end = detection.occurred_end or detection.occurred_start
398
+ start = detection.occurred_start or detection.occurred_end
399
+ if end is None or start is None or end.tzinfo is None or start.tzinfo is None:
400
+ return True
401
+ return end >= window_start and start <= window_end
402
+
403
+
404
+ def scoped_to_window(analysis: Analysis) -> Analysis:
405
+ """The same analysis, reporting only the findings the window reaches.
406
+
407
+ A window selects whole transcripts by file mtime, so a session written
408
+ inside it is collected entire and every rule walks all of its turns. A
409
+ long session still being typed into therefore carried its whole history
410
+ into whatever range the caller asked for: a 538-turn session last active a
411
+ minute ago put an 18-minute-old block in a five-minute window.
412
+
413
+ Only ever narrowing, and that is not a coincidence. A finding's clock
414
+ comes from the turns it cites, and a transcript's mtime is its last write,
415
+ so mtime is never earlier than `occurred_end`. A finding inside the window
416
+ therefore always sits in a file the window already admitted -- this can
417
+ drop what the mtime rule over-admitted, and can never need to recover
418
+ anything it excluded.
419
+
420
+ Applied by `analyse`, so `detect`, `monitor` and `sync detect` cannot
421
+ disagree about one `--since` (`cli.py` states that invariant). Not by
422
+ `analyse_sessions`: the daemon passes a watermark rather than a range a
423
+ person chose, and its findings are the record that feeds the store, the
424
+ webhook and Fleet, where a window has no business narrowing anything.
425
+ """
426
+ kept = tuple(
427
+ d
428
+ for d in analysis.run.detections
429
+ if _reaches(d, analysis.window_start, analysis.window_end)
430
+ )
431
+ if len(kept) == len(analysis.run.detections):
432
+ return analysis
433
+ return replace(analysis, run=replace(analysis.run, detections=kept))
434
+
435
+
376
436
  def _unplaced(sessions: SessionView, *, window_start: datetime, window_end: datetime) -> Analysis:
377
437
  """The sessions as read, before anything has been correlated or judged.
378
438
 
@@ -498,11 +558,13 @@ def analyse_progressively(
498
558
  stage carrying fewer sessions than the preview did would make the count
499
559
  on the page go backwards.
500
560
  """
501
- return Analysis(
502
- run=run,
503
- view=replace(view, sessions=sessions),
504
- window_start=window_start,
505
- window_end=window_end,
561
+ return scoped_to_window(
562
+ Analysis(
563
+ run=run,
564
+ view=replace(view, sessions=sessions),
565
+ window_start=window_start,
566
+ window_end=window_end,
567
+ )
506
568
  )
507
569
 
508
570
  # The detector derives these from the view, and a stage that has not judged
stacktrace_cli/cli.py CHANGED
@@ -40,6 +40,7 @@ from .sessions.access import collect_sessions
40
40
  from .sessions.render import render_json, render_text
41
41
  from .telemetry import emit_error
42
42
  from .telemetry.cli import telemetry as telemetry_cmd
43
+ from .webhook.cli import webhook as webhook_cmd
43
44
 
44
45
  PASSTHROUGH: Final = ("scan", "bom", "policy")
45
46
 
@@ -586,3 +587,4 @@ main.add_command(monitor)
586
587
  main.add_command(daemon_cmd, "daemon")
587
588
  main.add_command(findings)
588
589
  main.add_command(telemetry_cmd, "telemetry")
590
+ main.add_command(webhook_cmd, "webhook")
@@ -0,0 +1,39 @@
1
+ """One entry point for a Claude Code cloud environment's setup script.
2
+
3
+ A cloud environment's setup script runs once, as root, before the filesystem is
4
+ snapshotted and reused by every later session. What a session needs that is not
5
+ the CLI itself — the per-session boot hook and the asset identity — has to be
6
+ laid down there so it lands inside the snapshot.
7
+
8
+ Rather than paste that into the setup-script box, a setup script installs the
9
+ CLI and runs this:
10
+
11
+ UV_TOOL_DIR=/usr/local/share/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin \
12
+ uv tool install stacktrace-cli
13
+ stacktrace-cloud-setup
14
+
15
+ Both variables matter: setup runs as root, and `uv tool install`'s root
16
+ defaults put the tool's environment under `/root/.local/share/uv/tools` and the
17
+ linked executable under `/root/.local/bin` — both inside `/root`, which a later
18
+ non-root session user cannot traverse. `UV_TOOL_BIN_DIR` alone moves the
19
+ symlink but not the environment it points into, so `command -v stacktrace` would
20
+ find the binary while running it still fails; both must move.
21
+
22
+ The work itself is `setup.sh`, shipped beside this module so it travels in the
23
+ wheel. This shim only locates it and hands off, so the logic stays one auditable
24
+ bash file rather than being reimplemented in Python.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import sys
31
+ from pathlib import Path
32
+
33
+
34
+ def main() -> None:
35
+ script = Path(__file__).with_name("setup.sh")
36
+ # execvp so the process *becomes* bash: its exit status is the setup
37
+ # script's, which is what the environment reads to decide the session may
38
+ # start, and there is no Python frame left to swallow a signal.
39
+ os.execvp("bash", ["bash", str(script), *sys.argv[1:]])
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env bash
2
+ # Lay down everything a Claude Code cloud session needs, at environment setup.
3
+ #
4
+ # Run once by the environment's setup script, as root, before the filesystem is
5
+ # snapshotted. Two things, both of which must be inside that snapshot because
6
+ # the setup script does not run again for later sessions:
7
+ #
8
+ # 1. /usr/local/bin/stacktrace-boot.sh — the per-session hook that configures
9
+ # the remote and starts the daemon. Registered as a SessionStart hook in
10
+ # every home so it fires whichever user the session runs as.
11
+ # 2. The asset identity, in every home's ~/.config/stacktrace/asset-id, so
12
+ # `stacktrace remote sync` resolves it at the persisted rung (ADR-0065)
13
+ # instead of minting a fresh asset per session.
14
+ #
15
+ # The telemetry install id is deliberately NOT seeded here. Seeding it would
16
+ # make `read_install_id()` non-None before any event runs, so the one-time
17
+ # `installed` event (which `emit()` synthesizes only when the id is absent)
18
+ # would never fire. Left unseeded, the daemon's first in-session event mints the
19
+ # id and fires `installed` once per VM, correctly attributed `remote=true`
20
+ # because it runs inside the session. Counting one install per VM rather than
21
+ # per environment is ADR-0043's documented cloud tradeoff; per-environment is
22
+ # not cleanly achievable for an ephemeral VM (a setup-time emit would carry the
23
+ # wrong `remote` value and could not flush from a one-shot process).
24
+ #
25
+ # Every home rather than $HOME: setup runs as root and the user a session runs
26
+ # as is not documented, so writing all of them removes the guess. The asset id
27
+ # is unconditional — each environment instance gets its own, and a leftover from
28
+ # a previous snapshot must not win.
29
+ #
30
+ # Scope (ADR-0070): this seeds the snapshot and starts the daemon per session.
31
+ # It does not own daemon lifetime across a supervisor — that is ADR-0066, and a
32
+ # cloud VM is one session per VM, so the daemon dying with the VM is correct.
33
+ # The environment this targets is a fresh, single-tenant snapshot: it does not
34
+ # defend against adversarial or pre-existing symlinks and special files in a
35
+ # home, because that home does not exist until this run creates it.
36
+ #
37
+ # Options (all optional):
38
+ # --asset-id VALUE use this asset id instead of minting one; also taken
39
+ # from STACKTRACE_ASSET_EXTERNAL_ID in the environment
40
+ # --root DIR treat DIR as / (for testing)
41
+ #
42
+ # The remote token and API URL, and the Slack webhook URL, are read by the boot
43
+ # hook at session time from STACKTRACE_REMOTE_TOKEN, STACKTRACE_REMOTE_API_URL
44
+ # and STACKTRACE_SLACK_WEBHOOK_URL, not here: they belong to the environment's
45
+ # variables, not to the snapshot this seeds. The webhook in particular must be
46
+ # configured by the boot hook and not by this script, because
47
+ # `webhook configure` writes `$HOME/.config/stacktrace/webhook.toml` and this
48
+ # script runs as root — a webhook configured here would land in /root and the
49
+ # daemon, running as the session user, would read a home that has none.
50
+ set -uo pipefail
51
+
52
+ ASSET_ID="${STACKTRACE_ASSET_EXTERNAL_ID:-}"
53
+ ROOT=""
54
+
55
+ while [ $# -gt 0 ]; do
56
+ case "$1" in
57
+ # `$# -ge 2` before the shift: without it, `--root` with no operand leaves
58
+ # the positional parameters unchanged under `set -u` with no `set -e`, and
59
+ # the loop spins forever — a malformed invocation would hang setup.
60
+ --asset-id) [ $# -ge 2 ] || { echo "✗ --asset-id needs a value" >&2; exit 2; }; ASSET_ID="$2"; shift 2 ;;
61
+ --root) [ $# -ge 2 ] || { echo "✗ --root needs a value" >&2; exit 2; }; ROOT="$2"; shift 2 ;;
62
+ -h|--help) sed -n '2,49p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
63
+ *) echo "✗ unknown argument: $1" >&2; exit 2 ;;
64
+ esac
65
+ done
66
+
67
+ LOG="${ROOT}/var/log/stacktrace-cloud-setup.log"
68
+ mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
69
+ exec > >(tee -a "$LOG") 2>&1
70
+ echo "=== stacktrace cloud setup $(date -u +%FT%TZ) ==="
71
+
72
+ # ---- identities ----------------------------------------------------------
73
+ _uuid() {
74
+ if [ -r /proc/sys/kernel/random/uuid ]; then
75
+ cat /proc/sys/kernel/random/uuid
76
+ else
77
+ python3 -c 'import uuid; print(uuid.uuid4())'
78
+ fi
79
+ }
80
+
81
+ [ -n "$ASSET_ID" ] || ASSET_ID="cloud-$(_uuid)"
82
+ # remote/identity.py:_is_identifier rejects whitespace, non-printable
83
+ # characters, and anything over 255 characters. A value this accepts but the
84
+ # runtime rejects is worse than no file: every session reads it, rejects it,
85
+ # and mints a fresh asset — the per-session proliferation this exists to stop.
86
+ case "$ASSET_ID" in
87
+ *[[:space:]]*|"") echo "✗ asset id must be non-empty with no whitespace" >&2; exit 2 ;;
88
+ esac
89
+ if [ "${#ASSET_ID}" -gt 255 ]; then
90
+ echo "✗ asset id must be 255 characters or fewer" >&2
91
+ exit 2
92
+ fi
93
+ if ! python3 -c 'import sys; sys.exit(0 if sys.argv[1].isprintable() else 1)' "$ASSET_ID"; then
94
+ echo "✗ asset id must contain no non-printable characters" >&2
95
+ exit 2
96
+ fi
97
+ # ---- the per-session boot hook -------------------------------------------
98
+ # Failing hard here: if the hook cannot be written, every session's SessionStart
99
+ # command points at a file that does not exist and the daemon never starts —
100
+ # a silent total failure worse than a setup that stops and says so.
101
+ BOOT="${ROOT}/usr/local/bin/stacktrace-boot.sh"
102
+ mkdir -p "$(dirname "$BOOT")" || { echo "✗ cannot create $(dirname "$BOOT")" >&2; exit 1; }
103
+ cat > "$BOOT" <<'BOOTSCRIPT' || { echo "✗ cannot write $BOOT" >&2; exit 1; }
104
+ #!/usr/bin/env bash
105
+ # Configure the remote and start the daemon for this cloud session. No-op
106
+ # anywhere else. The asset id is already on disk from setup, so this does not
107
+ # touch it: it only configures the token and starts the daemon, which resolves
108
+ # the persisted asset id and mints the telemetry id on its first event.
109
+ #
110
+ # `daemon run` is started detached, not through a supervisor: a cloud VM runs
111
+ # one session, so the daemon's lifetime is the VM's (ADR-0070). Where a host
112
+ # runs several sessions, ADR-0066's `daemon start` replaces this one line.
113
+ set -u
114
+ [ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0
115
+ command -v stacktrace >/dev/null 2>&1 || exit 0
116
+ # A webhook is an independent subscriber, not a Fleet feature (ADR-0067): a
117
+ # session with only STACKTRACE_SLACK_WEBHOOK_URL set still needs the daemon
118
+ # running to drain it, so the remote token gates `remote configure` alone,
119
+ # never this whole hook.
120
+ if [ -z "${STACKTRACE_REMOTE_TOKEN:-}" ] && [ -z "${STACKTRACE_SLACK_WEBHOOK_URL:-}" ]; then
121
+ exit 0
122
+ fi
123
+
124
+ STATE="$HOME/.local/state/stacktrace"
125
+ mkdir -p "$STATE" && chmod 700 "$STATE"
126
+
127
+ if [ -n "${STACKTRACE_REMOTE_TOKEN:-}" ]; then
128
+ stacktrace remote configure \
129
+ --token "$STACKTRACE_REMOTE_TOKEN" \
130
+ --api-url "${STACKTRACE_REMOTE_API_URL:-https://api.stacktrace.ai}" >/dev/null 2>&1 || true
131
+ fi
132
+
133
+ # The Slack endpoint is session-time configuration like the token above, not
134
+ # part of the snapshot: written here it lands in the session user's home, which
135
+ # is the home the daemon started below reads `webhook.toml` from. Rotating the
136
+ # URL is then an environment-variable change, with no snapshot to rebuild.
137
+ # `--secret` is always passed explicitly, empty if unset: the option prompts
138
+ # when neither it nor STACKTRACE_WEBHOOK_SECRET is given, and a prompt in a hook
139
+ # with no terminal aborts the command. Configured before the daemon starts, so
140
+ # the daemon reads the endpoint at startup rather than waiting for a drain.
141
+ if [ -n "${STACKTRACE_SLACK_WEBHOOK_URL:-}" ]; then
142
+ stacktrace webhook configure \
143
+ --url "$STACKTRACE_SLACK_WEBHOOK_URL" \
144
+ --secret "${STACKTRACE_WEBHOOK_SECRET:-}" >/dev/null 2>&1 || true
145
+ fi
146
+
147
+ # Streams redirected because own_std_streams only re-points fd 1/2 on log
148
+ # rollover; without this the daemon inherits the hook's pipe to Claude Code.
149
+ if ! stacktrace daemon status 2>/dev/null | grep -q '^running: yes'; then
150
+ setsid nohup stacktrace daemon run </dev/null >>"$STATE/daemon.log" 2>&1 &
151
+ fi
152
+ exit 0
153
+ BOOTSCRIPT
154
+ chmod 755 "$BOOT" || { echo "✗ cannot chmod $BOOT" >&2; exit 1; }
155
+ echo "✓ $BOOT"
156
+
157
+ # ---- seed every home -----------------------------------------------------
158
+ # Every existing home is seeded because the future session user is unknown, so a
159
+ # home that exists but cannot be seeded is a failure, not a skip: the session
160
+ # might run as exactly that user and find no daemon. `failed` records any such
161
+ # home and fails the whole run at the end, covering the mixed-success case where
162
+ # one home seeds and another does not. A candidate that is not a directory (the
163
+ # `/home/*` glob with no match, a stray file) is a genuine skip, not a home.
164
+ seeded=0
165
+ failed=0
166
+ for home in "${ROOT}"/root "${ROOT}"/home/*; do
167
+ [ -d "$home" ] || continue
168
+
169
+ cfg="$home/.config/stacktrace"
170
+ if ! install -d -m 700 "$cfg" 2>/dev/null; then
171
+ echo "✗ cannot create $cfg — not seeding $home" >&2; failed=1; continue
172
+ fi
173
+
174
+ if ! printf '%s\n' "$ASSET_ID" > "$cfg/asset-id" 2>/dev/null; then
175
+ echo "✗ cannot write $cfg/asset-id — not seeding $home" >&2; failed=1; continue
176
+ fi
177
+ chmod 600 "$cfg/asset-id" 2>/dev/null || true
178
+
179
+ # Register the SessionStart hook, merging so any existing settings survive.
180
+ # The hook is the point of seeding a home — a home whose registration fails
181
+ # (an unwritable path, or a settings.json that is valid JSON of an unexpected
182
+ # shape) is a failure, not a skip, or setup would exit 0 having left that
183
+ # session with no daemon.
184
+ if ! python3 - "$home" <<'PY'
185
+ import json, pathlib, sys
186
+ home = pathlib.Path(sys.argv[1])
187
+ p = home / ".claude" / "settings.json"
188
+ command = "/usr/local/bin/stacktrace-boot.sh"
189
+ try:
190
+ p.parent.mkdir(parents=True, exist_ok=True)
191
+ if p.exists():
192
+ # Present but unreadable or not valid JSON: do not clobber a real file.
193
+ s = json.loads(p.read_text())
194
+ else:
195
+ s = {}
196
+ except Exception:
197
+ sys.exit(1)
198
+ # A settings.json Claude Code accepts is a JSON object with object-shaped
199
+ # `hooks` and a list-shaped `SessionStart`; anything else is a shape this must
200
+ # not silently rewrite, so it fails the home rather than guessing.
201
+ if not isinstance(s, dict):
202
+ sys.exit(1)
203
+ hooks = s.setdefault("hooks", {})
204
+ if not isinstance(hooks, dict):
205
+ sys.exit(1)
206
+ starts = hooks.setdefault("SessionStart", [])
207
+ if not isinstance(starts, list):
208
+ sys.exit(1)
209
+ # Dedup only against an entry that actually fires on the events we need. An
210
+ # existing entry carrying this command under a narrower matcher (say `compact`)
211
+ # does not start the daemon at session start, so it must not suppress the
212
+ # `startup|resume` registration. A matcher covers us when it names both events,
213
+ # or when it is absent/empty — Claude Code treats that as matching every event.
214
+ required = {"startup", "resume"}
215
+
216
+
217
+ def covers(entry):
218
+ matcher = entry.get("matcher")
219
+ if matcher is None or matcher == "":
220
+ return True
221
+ return required.issubset({t.strip() for t in str(matcher).split("|")})
222
+
223
+
224
+ already = any(
225
+ isinstance(e, dict)
226
+ and covers(e)
227
+ and any(isinstance(h, dict) and h.get("command") == command for h in e.get("hooks", []))
228
+ for e in starts
229
+ )
230
+ if not already:
231
+ starts.append({"matcher": "startup|resume",
232
+ "hooks": [{"type": "command", "command": command}]})
233
+ try:
234
+ p.write_text(json.dumps(s, indent=2) + "\n")
235
+ except Exception:
236
+ sys.exit(1)
237
+ PY
238
+ then
239
+ echo "✗ could not register the hook in $home — not seeding it" >&2
240
+ failed=1
241
+ continue
242
+ fi
243
+
244
+ # Root created these directories, so they are root-owned; give them to the
245
+ # home's owner or the session user cannot write its own config. The parent
246
+ # `.config` that `install -d` created is chowned as well as the leaf, since a
247
+ # root-owned .config blocks the user from creating any other config beneath it.
248
+ for d in "$home/.config" "$cfg" "$home/.claude"; do
249
+ [ -e "$d" ] && chown --reference="$home" "$d" 2>/dev/null || true
250
+ done
251
+ chown --reference="$home" "$cfg/asset-id" \
252
+ "$home/.claude/settings.json" 2>/dev/null || true
253
+
254
+ echo "✓ seeded $home"
255
+ seeded=$((seeded + 1))
256
+ done
257
+
258
+ if [ "$seeded" -eq 0 ]; then
259
+ echo "✗ no home directory was writable — every session will mint its own identity" >&2
260
+ exit 1
261
+ fi
262
+
263
+ if [ "$failed" -ne 0 ]; then
264
+ echo "✗ one or more home directories could not be seeded — a session running as" \
265
+ "that user would have no daemon, so failing the whole setup rather than" \
266
+ "accepting a snapshot that works for some users and not others" >&2
267
+ exit 1
268
+ fi
269
+
270
+ command -v stacktrace >/dev/null 2>&1 || echo "! stacktrace is not on PATH yet; the boot hook waits for it" >&2
271
+
272
+ echo "✓ asset id: $ASSET_ID"
273
+ echo "✓ homes seeded: $seeded"
274
+ exit 0
@@ -50,9 +50,9 @@ class _PrivateRotatingFileHandler(RotatingFileHandler):
50
50
 
51
51
  def doRollover(self) -> None:
52
52
  super().doRollover()
53
- # `ensure_daemon` pointed this process's stdout and stderr at the file
54
- # just renamed away. Follow it, or a crash traceback lands in the
55
- # backup and is deleted with it at the next rollover.
53
+ # The detached launcher points this process's stdout and stderr at
54
+ # the file just renamed away. Follow it, or a crash traceback lands in
55
+ # the backup and is deleted with it at the next rollover.
56
56
  if self._own_std_streams and self.stream is not None:
57
57
  sys.stdout.flush()
58
58
  sys.stderr.flush()