stacktrace-cli 0.9.0__py3-none-any.whl → 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. {stacktrace_cli → stacktrace}/__init__.py +5 -1
  2. stacktrace/__main__.py +14 -0
  3. {stacktrace_cli → stacktrace}/analysis.py +7 -7
  4. {stacktrace_cli → stacktrace}/cli.py +2 -0
  5. {stacktrace_cli → stacktrace}/cloud/__init__.py +13 -2
  6. {stacktrace_cli → stacktrace}/cloud/setup.sh +191 -9
  7. stacktrace/core/__init__.py +27 -0
  8. stacktrace/core/observations.py +222 -0
  9. {stacktrace_cli → stacktrace}/correlate/acquire.py +8 -2
  10. {stacktrace_cli → stacktrace}/correlate/composition.py +46 -1
  11. {stacktrace_cli → stacktrace}/correlate/join.py +104 -149
  12. {stacktrace_cli → stacktrace}/correlate/observed.py +20 -21
  13. {stacktrace_cli → stacktrace}/correlate/orchestrate.py +54 -27
  14. {stacktrace_cli → stacktrace}/correlate/record.py +5 -5
  15. {stacktrace_cli → stacktrace}/correlate/render.py +8 -8
  16. {stacktrace_cli → stacktrace}/cost/baseline.py +185 -130
  17. stacktrace/cost/component_usage.py +324 -0
  18. {stacktrace_cli → stacktrace}/cost/components.py +9 -9
  19. {stacktrace_cli → stacktrace}/cost/ledger.py +2 -2
  20. {stacktrace_cli → stacktrace}/cost/measures.py +16 -21
  21. stacktrace/cost/org_baselines.py +258 -0
  22. stacktrace/cost/reasons.py +592 -0
  23. stacktrace/cost/rules.py +662 -0
  24. {stacktrace_cli → stacktrace}/cost/runs.py +46 -23
  25. {stacktrace_cli → stacktrace}/cost/tokens.py +1 -1
  26. {stacktrace_cli → stacktrace}/cost/totals.py +2 -2
  27. stacktrace/cost/usage.py +298 -0
  28. stacktrace/daemon/accounts.py +272 -0
  29. {stacktrace_cli → stacktrace}/daemon/activity_log.py +3 -3
  30. {stacktrace_cli → stacktrace}/daemon/bom.py +1 -1
  31. {stacktrace_cli → stacktrace}/daemon/cli.py +49 -18
  32. {stacktrace_cli → stacktrace}/daemon/client.py +42 -1
  33. {stacktrace_cli → stacktrace}/daemon/composition.py +54 -2
  34. {stacktrace_cli → stacktrace}/daemon/config.py +11 -2
  35. {stacktrace_cli → stacktrace}/daemon/costs.py +208 -310
  36. {stacktrace_cli → stacktrace}/daemon/db.py +268 -1
  37. {stacktrace_cli → stacktrace}/daemon/fleet.py +92 -28
  38. {stacktrace_cli → stacktrace}/daemon/identity.py +1 -1
  39. {stacktrace_cli → stacktrace}/daemon/lifecycle.py +2 -2
  40. stacktrace/daemon/observer.py +755 -0
  41. {stacktrace_cli → stacktrace}/daemon/paths.py +1 -1
  42. {stacktrace_cli → stacktrace}/daemon/presentation.py +50 -4
  43. {stacktrace_cli → stacktrace}/daemon/runtime.py +171 -26
  44. {stacktrace_cli → stacktrace}/daemon/scan.py +3 -3
  45. {stacktrace_cli → stacktrace}/daemon/server.py +18 -4
  46. stacktrace/daemon/store.py +2454 -0
  47. stacktrace/daemon/usage.py +1170 -0
  48. {stacktrace_cli → stacktrace}/daemon/webhook.py +6 -6
  49. {stacktrace_cli → stacktrace}/detector/analyzer.py +1 -1
  50. {stacktrace_cli → stacktrace}/detector/blocked.py +1 -1
  51. {stacktrace_cli → stacktrace}/detector/cache.py +8 -4
  52. {stacktrace_cli → stacktrace}/detector/deterministic.py +22 -23
  53. {stacktrace_cli → stacktrace}/detector/finding.py +4 -4
  54. {stacktrace_cli → stacktrace}/detector/history.py +3 -3
  55. {stacktrace_cli → stacktrace}/detector/jev.py +1 -1
  56. {stacktrace_cli → stacktrace}/detector/priors.py +5 -5
  57. {stacktrace_cli → stacktrace}/detector/prompts/__init__.py +0 -5
  58. {stacktrace_cli → stacktrace}/detector/reasoning.py +21 -21
  59. {stacktrace_cli → stacktrace}/detector/render.py +2 -2
  60. {stacktrace_cli → stacktrace}/detector/rules.py +12 -15
  61. {stacktrace_cli → stacktrace}/detector/run.py +16 -16
  62. stacktrace/detector/secrets.py +519 -0
  63. {stacktrace_cli → stacktrace}/detector/verdict.py +2 -2
  64. {stacktrace_cli → stacktrace}/detector/windows.py +11 -11
  65. stacktrace/gateway/__init__.py +1 -0
  66. stacktrace/gateway/analyzer.py +392 -0
  67. stacktrace/gateway/check.py +117 -0
  68. stacktrace/gateway/cli.py +63 -0
  69. stacktrace/gateway/exchange.py +77 -0
  70. stacktrace/gateway/formats/__init__.py +179 -0
  71. stacktrace/gateway/formats/_json.py +180 -0
  72. stacktrace/gateway/formats/anthropic.py +158 -0
  73. stacktrace/gateway/formats/bedrock.py +141 -0
  74. stacktrace/gateway/formats/body.py +196 -0
  75. stacktrace/gateway/formats/openai_chat.py +194 -0
  76. stacktrace/gateway/formats/openai_responses.py +186 -0
  77. stacktrace/gateway/service.py +117 -0
  78. stacktrace/gateway/session.py +274 -0
  79. stacktrace/gateway/sink.py +207 -0
  80. {stacktrace_cli → stacktrace}/monitor/__init__.py +1 -1
  81. stacktrace/monitor/costs.py +650 -0
  82. {stacktrace_cli → stacktrace}/monitor/inventory.py +5 -5
  83. {stacktrace_cli → stacktrace}/monitor/offline.py +3 -3
  84. {stacktrace_cli → stacktrace}/monitor/reasoning.py +1 -1
  85. {stacktrace_cli → stacktrace}/monitor/render.py +80 -36
  86. {stacktrace_cli → stacktrace}/monitor/server.py +31 -18
  87. {stacktrace_cli → stacktrace}/monitor/site/app.js +516 -135
  88. stacktrace/monitor/site/cost.js +1008 -0
  89. {stacktrace_cli → stacktrace}/monitor/site/fonts/OFL.txt +196 -97
  90. stacktrace/monitor/site/fonts/playfair-display-600-italic-latin.woff2 +0 -0
  91. {stacktrace_cli → stacktrace}/monitor/site/index.html +35 -11
  92. {stacktrace_cli → stacktrace}/monitor/site/inventory.js +5 -2
  93. stacktrace/monitor/site/styles.css +902 -0
  94. {stacktrace_cli → stacktrace}/monitor/verdicts.py +1 -1
  95. {stacktrace_cli → stacktrace}/monitor/watch.py +44 -17
  96. {stacktrace_cli → stacktrace}/options.py +1 -1
  97. {stacktrace_cli → stacktrace}/private_state.py +2 -3
  98. stacktrace/py.typed +0 -0
  99. {stacktrace_cli → stacktrace}/remote/cli.py +11 -11
  100. {stacktrace_cli → stacktrace}/remote/client.py +93 -1
  101. {stacktrace_cli → stacktrace}/remote/detect_payload.py +43 -23
  102. {stacktrace_cli → stacktrace}/remote/identity.py +39 -31
  103. {stacktrace_cli → stacktrace}/remote/payload.py +2 -2
  104. {stacktrace_cli → stacktrace}/remote/policy.py +3 -3
  105. {stacktrace_cli → stacktrace}/remote/policy_service.py +3 -3
  106. {stacktrace_cli → stacktrace}/remote/redact.py +0 -7
  107. {stacktrace_cli → stacktrace}/remote/spool.py +10 -8
  108. {stacktrace_cli → stacktrace}/remote/sync.py +6 -6
  109. {stacktrace_cli → stacktrace}/remote/sync_detect.py +14 -14
  110. {stacktrace_cli → stacktrace}/remote/upload_contract.py +138 -5
  111. stacktrace/sdk.py +232 -0
  112. stacktrace/sessions/access.py +222 -0
  113. {stacktrace_cli → stacktrace}/sessions/outcome.py +1 -1
  114. {stacktrace_cli → stacktrace}/sessions/protocols.py +18 -0
  115. {stacktrace_cli → stacktrace}/sessions/render.py +3 -3
  116. {stacktrace_cli → stacktrace}/telemetry/events.py +9 -3
  117. {stacktrace_cli → stacktrace}/telemetry/state.py +1 -1
  118. {stacktrace_cli → stacktrace}/webhook/cli.py +6 -6
  119. {stacktrace_cli → stacktrace}/webhook/config.py +1 -1
  120. {stacktrace_cli-0.9.0.dist-info → stacktrace_cli-0.11.0.dist-info}/METADATA +39 -7
  121. stacktrace_cli-0.11.0.dist-info/RECORD +158 -0
  122. stacktrace_cli-0.11.0.dist-info/entry_points.txt +3 -0
  123. stacktrace_cli/__main__.py +0 -16
  124. stacktrace_cli/cost/rules.py +0 -1138
  125. stacktrace_cli/daemon/observer.py +0 -670
  126. stacktrace_cli/daemon/store.py +0 -1209
  127. stacktrace_cli/detector/secrets.py +0 -205
  128. stacktrace_cli/monitor/costs.py +0 -375
  129. stacktrace_cli/monitor/site/cost.js +0 -436
  130. stacktrace_cli/monitor/site/styles.css +0 -586
  131. stacktrace_cli/sessions/access.py +0 -103
  132. stacktrace_cli-0.9.0.dist-info/RECORD +0 -132
  133. stacktrace_cli-0.9.0.dist-info/entry_points.txt +0 -3
  134. {stacktrace_cli → stacktrace}/build.py +0 -0
  135. {stacktrace_cli → stacktrace}/correlate/__init__.py +0 -0
  136. {stacktrace_cli → stacktrace}/correlate/project_map.py +0 -0
  137. {stacktrace_cli → stacktrace}/cost/__init__.py +0 -0
  138. {stacktrace_cli → stacktrace}/cost/clock.py +0 -0
  139. {stacktrace_cli → stacktrace}/cost/evidence.py +0 -0
  140. {stacktrace_cli → stacktrace}/cost/models.py +0 -0
  141. {stacktrace_cli → stacktrace}/daemon/__init__.py +0 -0
  142. {stacktrace_cli → stacktrace}/daemon/jobs.py +0 -0
  143. {stacktrace_cli → stacktrace}/daemon/protocol.py +0 -0
  144. {stacktrace_cli → stacktrace}/daemon/service.py +0 -0
  145. {stacktrace_cli → stacktrace}/detector/__init__.py +0 -0
  146. {stacktrace_cli → stacktrace}/detector/prompts/jev-v1/stacktrace-deceptive-completion.json +0 -0
  147. {stacktrace_cli → stacktrace}/detector/prompts/jev-v2/stacktrace-deceptive-completion.json +0 -0
  148. {stacktrace_cli → stacktrace}/detector/prompts/jev-v2/stacktrace-infra-unsafe-action.json +0 -0
  149. {stacktrace_cli → stacktrace}/detector/prompts/v1/exclusions.md +0 -0
  150. {stacktrace_cli → stacktrace}/detector/prompts/v1/framing.md +0 -0
  151. {stacktrace_cli → stacktrace}/detector/prompts/v1/stacktrace-deceptive-completion.md +0 -0
  152. {stacktrace_cli → stacktrace}/monitor/site/apple-icon.png +0 -0
  153. {stacktrace_cli → stacktrace}/monitor/site/favicon.ico +0 -0
  154. {stacktrace_cli → stacktrace}/monitor/site/fonts/dm-mono-400-latin.woff2 +0 -0
  155. {stacktrace_cli → stacktrace}/monitor/site/fonts/dm-mono-500-latin.woff2 +0 -0
  156. {stacktrace_cli → stacktrace}/monitor/site/fonts/dm-sans-latin.woff2 +0 -0
  157. {stacktrace_cli → stacktrace}/monitor/site/icon.svg +0 -0
  158. {stacktrace_cli → stacktrace}/monitor/state.py +0 -0
  159. {stacktrace_cli → stacktrace}/remote/__init__.py +0 -0
  160. {stacktrace_cli → stacktrace}/remote/config.py +0 -0
  161. {stacktrace_cli → stacktrace}/remote/policy_worker.py +0 -0
  162. {stacktrace_cli → stacktrace}/sessions/__init__.py +0 -0
  163. {stacktrace_cli → stacktrace}/sessions/index.py +0 -0
  164. {stacktrace_cli → stacktrace}/sessions/lineage.py +0 -0
  165. {stacktrace_cli → stacktrace}/telemetry/__init__.py +0 -0
  166. {stacktrace_cli → stacktrace}/telemetry/cli.py +0 -0
  167. {stacktrace_cli → stacktrace}/telemetry/posthog.py +0 -0
  168. {stacktrace_cli → stacktrace}/webhook/__init__.py +0 -0
  169. {stacktrace_cli-0.9.0.dist-info → stacktrace_cli-0.11.0.dist-info}/WHEEL +0 -0
@@ -2,9 +2,13 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.9.0"
5
+ __version__ = "0.11.0"
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
9
9
  # print onto the user's terminal.
10
10
  _logging.getLogger(__name__).addHandler(_logging.NullHandler())
11
+
12
+ from stacktrace.sdk import ObservationClient
13
+
14
+ __all__ = ["ObservationClient", "__version__"]
stacktrace/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Entry point shared by the ``stacktrace`` command and ``python -m stacktrace``.
2
+
3
+ The distribution's console script resolves to the Click group in
4
+ ``stacktrace.cli`` through this module.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .cli import main
10
+
11
+ __all__ = ["main"]
12
+
13
+ if __name__ == "__main__":
14
+ main()
@@ -26,19 +26,19 @@ from datetime import UTC, datetime, timedelta
26
26
  from pathlib import Path
27
27
  from typing import Any, Final
28
28
 
29
- from stacktrace_cli.correlate.orchestrate import Acquired, acquire_correlated_view
30
- from stacktrace_cli.correlate.project_map import parse_mapping
31
- from stacktrace_cli.correlate.record import CorrelatedSession, CorrelatedView, Coverage
32
- from stacktrace_cli.detector.cache import VerdictCache
33
- from stacktrace_cli.detector.history import DriftHistory
34
- from stacktrace_cli.detector.run import (
29
+ from stacktrace.correlate.orchestrate import Acquired, acquire_correlated_view
30
+ from stacktrace.correlate.project_map import parse_mapping
31
+ from stacktrace.correlate.record import CorrelatedSession, CorrelatedView, Coverage
32
+ from stacktrace.detector.cache import VerdictCache
33
+ from stacktrace.detector.history import DriftHistory
34
+ from stacktrace.detector.run import (
35
35
  DEFAULT_BUDGET,
36
36
  DEFAULT_SAMPLE_BUDGET,
37
37
  NO_ANALYZER,
38
38
  DetectorRun,
39
39
  run_detector,
40
40
  )
41
- from stacktrace_cli.sessions.access import SessionView, collect_sessions
41
+ from stacktrace.sessions.access import SessionView, collect_sessions
42
42
 
43
43
 
44
44
  @dataclass(frozen=True)
@@ -33,6 +33,7 @@ from .detector.history import DriftHistory, default_history_path
33
33
  from .detector.render import render_json as render_detections_json
34
34
  from .detector.render import render_text as render_detections_text
35
35
  from .detector.run import DEFAULT_BUDGET, DEFAULT_SAMPLE_BUDGET
36
+ from .gateway.cli import guard as guard_cmd
36
37
  from .monitor.server import serve
37
38
  from .options import analyzer_option, resolve_analyzer
38
39
  from .remote.cli import main as remote_cmd
@@ -637,5 +638,6 @@ main.add_command(detect)
637
638
  main.add_command(monitor)
638
639
  main.add_command(daemon_cmd, "daemon")
639
640
  main.add_command(findings)
641
+ main.add_command(guard_cmd, "guard")
640
642
  main.add_command(telemetry_cmd, "telemetry")
641
643
  main.add_command(webhook_cmd, "webhook")
@@ -2,8 +2,9 @@
2
2
 
3
3
  A cloud environment's setup script runs once, as root, before the filesystem is
4
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.
5
+ the CLI itself — the per-session boot hook, the asset identity, and how the hook
6
+ updates the CLI to the latest release (ADR-0123) — has to be laid down there so
7
+ it lands inside the snapshot.
7
8
 
8
9
  Rather than paste that into the setup-script box, a setup script installs the
9
10
  CLI and runs this:
@@ -38,6 +39,16 @@ from __future__ import annotations
38
39
 
39
40
  import os
40
41
  import sys
42
+
43
+
44
+ def in_cloud_session() -> bool:
45
+ """Whether this process runs inside a Claude Code cloud session, which
46
+ sets `CLAUDE_CODE_REMOTE=true` and never sets it locally (ADR-0043). The
47
+ daemon reads it from the environment the boot hook starts it in. A cloud
48
+ machine runs one session, for the person who started it (ADR-0121)."""
49
+ return os.environ.get("CLAUDE_CODE_REMOTE") == "true"
50
+
51
+
41
52
  from pathlib import Path
42
53
 
43
54
 
@@ -2,7 +2,7 @@
2
2
  # Lay down everything a Claude Code cloud session needs, at environment setup.
3
3
  #
4
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
5
+ # snapshotted. Three things, all of which must be inside that snapshot because
6
6
  # the setup script does not run again for later sessions:
7
7
  #
8
8
  # 1. /usr/local/bin/stacktrace-boot.sh — the per-session hook that configures
@@ -14,6 +14,11 @@
14
14
  # asset-display-prefix makes the fleet label `cloud (<id>)` rather than
15
15
  # the image's hostname (`vm`), which is the same on every box. Label
16
16
  # only: the key above is what identifies the asset.
17
+ # 3. /usr/local/lib/stacktrace/boot-worker.py, which the hook hands the
18
+ # update and the daemon start to, and /usr/local/etc/stacktrace/update.env,
19
+ # the uv and the directories the installer used, so the client is brought
20
+ # up to the latest release first (ADR-0123). update.env is not written
21
+ # when STACKTRACE_VERSION pins a version, or when uv is not on PATH.
17
22
  #
18
23
  # The telemetry install id is deliberately NOT seeded here. Seeding it would
19
24
  # make `read_install_id()` non-None before any event runs, so the one-time
@@ -38,8 +43,8 @@
38
43
  # home, because that home does not exist until this run creates it.
39
44
  #
40
45
  # Options (all optional):
41
- # --asset-id VALUE use this asset id instead of minting one; also taken
42
- # from STACKTRACE_ASSET_EXTERNAL_ID in the environment
46
+ # --asset-id VALUE use this asset id instead of minting a bare UUID; also
47
+ # taken from STACKTRACE_ASSET_EXTERNAL_ID in the environment
43
48
  # --root DIR treat DIR as / (for testing)
44
49
  #
45
50
  # The remote token and API URL, and the Slack webhook URL, are read by the boot
@@ -62,7 +67,7 @@ while [ $# -gt 0 ]; do
62
67
  # the loop spins forever — a malformed invocation would hang setup.
63
68
  --asset-id) [ $# -ge 2 ] || { echo "✗ --asset-id needs a value" >&2; exit 2; }; ASSET_ID="$2"; shift 2 ;;
64
69
  --root) [ $# -ge 2 ] || { echo "✗ --root needs a value" >&2; exit 2; }; ROOT="$2"; shift 2 ;;
65
- -h|--help) sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
70
+ -h|--help) sed -n '2,57p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
66
71
  *) echo "✗ unknown argument: $1" >&2; exit 2 ;;
67
72
  esac
68
73
  done
@@ -82,7 +87,9 @@ _uuid() {
82
87
  }
83
88
 
84
89
  DISPLAY_PREFIX="cloud"
85
- [ -n "$ASSET_ID" ] || ASSET_ID="cloud-$(_uuid)"
90
+ # A bare UUID, like every key `remote/identity.py` mints (ADR-0105). The kind of
91
+ # box is the label's business (asset-display-prefix below), not the key's.
92
+ [ -n "$ASSET_ID" ] || ASSET_ID="$(_uuid)"
86
93
  # remote/identity.py:_is_identifier rejects whitespace, non-printable
87
94
  # characters, and anything over 255 characters. A value this accepts but the
88
95
  # runtime rejects is worse than no file: every session reads it, rejects it,
@@ -103,9 +110,18 @@ fi
103
110
  # command points at a file that does not exist and the daemon never starts —
104
111
  # a silent total failure worse than a setup that stops and says so.
105
112
  BOOT="${ROOT}/usr/local/bin/stacktrace-boot.sh"
106
- mkdir -p "$(dirname "$BOOT")" || { echo "✗ cannot create $(dirname "$BOOT")" >&2; exit 1; }
107
- cat > "$BOOT" <<'BOOTSCRIPT' || { echo "✗ cannot write $BOOT" >&2; exit 1; }
108
- #!/usr/bin/env bash
113
+ # The worker the hook hands the update and the daemon start to, and where setup
114
+ # records how that worker updates the client (ADR-0123). Both are named in the
115
+ # hook itself, so the hook never guesses them.
116
+ WORKER="${ROOT}/usr/local/lib/stacktrace/boot-worker.py"
117
+ UPDATE_ENV="${ROOT}/usr/local/etc/stacktrace/update.env"
118
+ mkdir -p "$(dirname "$BOOT")" "$(dirname "$WORKER")" \
119
+ || { echo "✗ cannot create $(dirname "$BOOT") or $(dirname "$WORKER")" >&2; exit 1; }
120
+ {
121
+ printf '#!/usr/bin/env bash\n'
122
+ printf 'WORKER=%q\n' "$WORKER"
123
+ printf 'UPDATE_ENV=%q\n' "$UPDATE_ENV"
124
+ cat <<'BOOTSCRIPT'
109
125
  # Configure the remote and start the daemon for this cloud session. No-op
110
126
  # anywhere else. The asset id is already on disk from setup, so this does not
111
127
  # touch it: it only configures the token and starts the daemon, which resolves
@@ -148,20 +164,186 @@ if [ -n "${STACKTRACE_SLACK_WEBHOOK_URL:-}" ]; then
148
164
  --secret "${STACKTRACE_WEBHOOK_SECRET:-}" >/dev/null 2>&1 || true
149
165
  fi
150
166
 
167
+ # The snapshot holds the client this environment's setup installed, and setup
168
+ # never runs again, so the worker brings the client up to the latest release
169
+ # before it starts the daemon (ADR-0123). It runs detached, so the session never
170
+ # waits on the network, and one worker at a time decides, so no two overlapping
171
+ # hooks can replace the client's files under a daemon the other started.
172
+ # Without python3 the hook starts the daemon as it always has, with no update.
173
+ #
151
174
  # Streams redirected because own_std_streams only re-points fd 1/2 on log
152
175
  # rollover; without this the daemon inherits the hook's pipe to Claude Code.
153
176
  # --no-reasoning is explicit, not just `run`'s own default (ADR-0081): this
154
177
  # hook never provisions TYPESAFE_API_KEY itself, but a cloud VM's ambient
155
178
  # environment is not something this script controls or can audit ahead of
156
179
  # time, and this surface has nobody present to notice if that changed.
157
- if ! stacktrace daemon status 2>/dev/null | grep -q '^running: yes'; then
180
+ if command -v python3 >/dev/null 2>&1 && [ -r "$WORKER" ]; then
181
+ if command -v setsid >/dev/null 2>&1; then
182
+ setsid nohup python3 "$WORKER" "$STATE" "$UPDATE_ENV" </dev/null >/dev/null 2>&1 &
183
+ else
184
+ nohup python3 "$WORKER" "$STATE" "$UPDATE_ENV" </dev/null >/dev/null 2>&1 &
185
+ fi
186
+ elif ! stacktrace daemon status 2>/dev/null | grep -q '^running: yes'; then
158
187
  setsid nohup stacktrace daemon run --no-reasoning </dev/null >>"$STATE/daemon.log" 2>&1 &
159
188
  fi
160
189
  exit 0
161
190
  BOOTSCRIPT
191
+ } > "$BOOT" || { echo "✗ cannot write $BOOT" >&2; exit 1; }
162
192
  chmod 755 "$BOOT" || { echo "✗ cannot chmod $BOOT" >&2; exit 1; }
163
193
  echo "✓ $BOOT"
164
194
 
195
+ cat > "$WORKER" <<'WORKERSCRIPT' || { echo "✗ cannot write $WORKER" >&2; exit 1; }
196
+ """Bring a cloud session's client up to the latest release, then start its
197
+ daemon (ADR-0123). Run detached by stacktrace-boot.sh, with the state
198
+ directory and the file setup recorded the install in.
199
+
200
+ One worker at a time holds `boot.lock`, from its own `daemon status` check
201
+ until the daemon it starts answers. So a worker never replaces the client's
202
+ files while a daemon runs or is starting: a second hook's worker waits, checks
203
+ again, and finds the daemon up.
204
+ """
205
+
206
+ import fcntl
207
+ import os
208
+ import pwd
209
+ import shlex
210
+ import subprocess
211
+ import sys
212
+ import time
213
+ from datetime import datetime, timezone
214
+
215
+ STATE, UPDATE_ENV = sys.argv[1], sys.argv[2]
216
+ #: How long an update may take before the installed client runs instead.
217
+ UPDATE_SECONDS = 120
218
+ #: How long the lock waits for the daemon this worker started to answer.
219
+ START_SECONDS = 30
220
+
221
+
222
+ def note(text):
223
+ with open(os.path.join(STATE, "update.log"), "a", encoding="utf-8") as log:
224
+ log.write(f"{datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ} {text}\n")
225
+
226
+
227
+ def running():
228
+ """What `daemon status` says: yes, no, unresponsive, or "" for no answer."""
229
+ try:
230
+ status = subprocess.run(
231
+ ["stacktrace", "daemon", "status"], capture_output=True, text=True, timeout=30
232
+ ).stdout
233
+ except (OSError, subprocess.SubprocessError):
234
+ return ""
235
+ for line in status.splitlines():
236
+ if line.startswith("running: "):
237
+ return line[len("running: "):].strip()
238
+ return ""
239
+
240
+
241
+ def recorded():
242
+ """The uv and the UV_* directories setup wrote, as `KEY=value` lines."""
243
+ try:
244
+ lines = open(UPDATE_ENV, encoding="utf-8").read().splitlines()
245
+ except OSError:
246
+ return {}
247
+ values = {}
248
+ for line in lines:
249
+ words = shlex.split(line)
250
+ if words[:1] == ["export"]:
251
+ words = words[1:]
252
+ if len(words) == 1 and "=" in words[0]:
253
+ key, value = words[0].split("=", 1)
254
+ values[key] = value
255
+ return values
256
+
257
+
258
+ def update():
259
+ """Upgrade the tool install setup recorded, as a user who can write it;
260
+ else say why not. No file: the client is pinned, or setup found no uv."""
261
+ values = recorded()
262
+ if not values:
263
+ return
264
+ uv = values.get("STACKTRACE_UV", "")
265
+ directories = [values[key] for key in ("UV_TOOL_DIR", "UV_TOOL_BIN_DIR") if values.get(key)]
266
+ if not (uv and os.access(uv, os.X_OK)) or not all(os.access(d, os.W_OK) for d in directories):
267
+ user = pwd.getpwuid(os.getuid()).pw_name
268
+ note(
269
+ f"not updating: {user} cannot run uv or write the install; "
270
+ "rebuild the environment to update the client"
271
+ )
272
+ return
273
+ environment = {**os.environ, **{k: v for k, v in values.items() if k.startswith("UV_")}}
274
+ with open(os.path.join(STATE, "update.log"), "a", encoding="utf-8") as log:
275
+ try:
276
+ subprocess.run(
277
+ [uv, "tool", "upgrade", "stacktrace-cli"],
278
+ env=environment,
279
+ stdin=subprocess.DEVNULL,
280
+ stdout=log,
281
+ stderr=subprocess.STDOUT,
282
+ timeout=UPDATE_SECONDS,
283
+ check=False,
284
+ )
285
+ except (OSError, subprocess.SubprocessError) as error:
286
+ note(f"update stopped: {type(error).__name__}; the installed client runs")
287
+
288
+
289
+ def main():
290
+ lock = os.open(os.path.join(STATE, "boot.lock"), os.O_CREAT | os.O_RDWR, 0o600)
291
+ fcntl.flock(lock, fcntl.LOCK_EX)
292
+ answer = running()
293
+ if answer == "yes":
294
+ return
295
+ # Only a status of no daemon lets the update run: `unresponsive` is a
296
+ # daemon holding its lock that has not answered yet, and an error or no
297
+ # answer says nothing. Either may be a live client.
298
+ if answer == "no":
299
+ update()
300
+ else:
301
+ note(f"not updating: daemon status said {answer or 'nothing'}, not that no daemon runs")
302
+ with open(os.path.join(STATE, "daemon.log"), "a", encoding="utf-8") as out:
303
+ subprocess.Popen(
304
+ ["stacktrace", "daemon", "run", "--no-reasoning"],
305
+ stdin=subprocess.DEVNULL,
306
+ stdout=out,
307
+ stderr=subprocess.STDOUT,
308
+ start_new_session=True,
309
+ )
310
+ deadline = time.monotonic() + START_SECONDS
311
+ while time.monotonic() < deadline and running() != "yes":
312
+ time.sleep(0.5)
313
+
314
+
315
+ if __name__ == "__main__":
316
+ main()
317
+ WORKERSCRIPT
318
+ chmod 644 "$WORKER" || { echo "✗ cannot chmod $WORKER" >&2; exit 1; }
319
+ echo "✓ $WORKER"
320
+
321
+ # ---- how the boot hook keeps the client current ----------------------------
322
+ # The uv and the directories the installer used (remote-install.sh exports
323
+ # them before running this), so the boot hook updates the same tool install
324
+ # (ADR-0123). Nothing is recorded when STACKTRACE_VERSION pins a version, or
325
+ # when uv is not found: sessions then run the client this setup installed.
326
+ # Not fatal: without it a session runs the installed client, as before.
327
+ UV_BIN="$(command -v uv 2>/dev/null || true)"
328
+ if [ -n "$UV_BIN" ] && { [ -z "${STACKTRACE_VERSION:-}" ] || [ "$STACKTRACE_VERSION" = "latest" ]; }; then
329
+ if mkdir -p "$(dirname "$UPDATE_ENV")" 2>/dev/null && {
330
+ printf 'STACKTRACE_UV=%q\n' "$UV_BIN"
331
+ for variable in UV_TOOL_DIR UV_TOOL_BIN_DIR UV_PYTHON_INSTALL_DIR UV_PYTHON_PREFERENCE; do
332
+ if [ -n "${!variable:-}" ]; then
333
+ printf 'export %s=%q\n' "$variable" "${!variable}"
334
+ fi
335
+ done
336
+ } >"$UPDATE_ENV" 2>/dev/null; then
337
+ chmod 644 "$UPDATE_ENV" 2>/dev/null || true
338
+ echo "✓ $UPDATE_ENV"
339
+ else
340
+ echo "! cannot write $UPDATE_ENV — sessions run the installed client" >&2
341
+ fi
342
+ else
343
+ rm -f "$UPDATE_ENV" 2>/dev/null || true
344
+ echo "· the client is pinned or uv is not on PATH — sessions run the installed client"
345
+ fi
346
+
165
347
  # ---- seed every home -----------------------------------------------------
166
348
  # Every existing home is seeded because the future session user is unknown, so a
167
349
  # home that exists but cannot be seeded is a failure, not a skip: the session
@@ -0,0 +1,27 @@
1
+ """Supported domain API for Stacktrace hosts, including Fleet.
2
+
3
+ Importing this module starts no services and configures no remote clients.
4
+ The distribution is ``stacktrace-cli``; its CLI and daemon are optional callers.
5
+ """
6
+
7
+ from stacktrace.core.observations import (
8
+ Decision,
9
+ DecisionRequest,
10
+ DecisionResponse,
11
+ Identifier,
12
+ Observation,
13
+ ObserveRequest,
14
+ StructuralPayload,
15
+ project_workflow,
16
+ )
17
+
18
+ __all__ = [
19
+ "Decision",
20
+ "DecisionRequest",
21
+ "DecisionResponse",
22
+ "Identifier",
23
+ "Observation",
24
+ "ObserveRequest",
25
+ "StructuralPayload",
26
+ "project_workflow",
27
+ ]
@@ -0,0 +1,222 @@
1
+ """Shared, storage-independent observation contract and evidence projection.
2
+
3
+ Stacktrace owns the envelope. Adapters map external conventions into optional
4
+ context and bounded JSON payloads. Capture profiles control content export.
5
+ """
6
+
7
+ import json
8
+ from datetime import datetime
9
+ from enum import StrEnum
10
+ from typing import Annotated, Any, Literal, TypedDict
11
+
12
+ from pydantic import AfterValidator, BaseModel, ConfigDict, Field, JsonValue
13
+
14
+ from stacktrace.detector.secrets import contains_credential_shape, is_secret_key_name
15
+
16
+
17
+ def _reject_credential_shaped(value: str) -> str:
18
+ """Reject known credential shapes, including namespaced and glued values."""
19
+ if contains_credential_shape(value):
20
+ raise ValueError("identifier fields must not contain credential-shaped values")
21
+ return value
22
+
23
+
24
+ Identifier = Annotated[
25
+ str,
26
+ Field(pattern=r"^[A-Za-z0-9_:-]{1,96}$"),
27
+ AfterValidator(_reject_credential_shaped),
28
+ ]
29
+
30
+
31
+ def _reject_calendar_invalid(value: str) -> str:
32
+ """The pattern alone accepts shapes like `2026-99-99T99:99:99Z`; `project_workflow`
33
+ later feeds this to `datetime.fromisoformat`, which would raise on such a value."""
34
+ datetime.fromisoformat(value)
35
+ return value
36
+
37
+
38
+ Timestamp = Annotated[
39
+ str,
40
+ Field(pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$"),
41
+ AfterValidator(_reject_calendar_invalid),
42
+ ]
43
+
44
+
45
+ class ClosedModel(BaseModel):
46
+ model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
47
+
48
+
49
+ class EventContext(ClosedModel):
50
+ agent_id: Identifier | None = None
51
+ # Labels are scoped to the authenticated source, never global session keys.
52
+ session_id: Identifier | None = None
53
+ correlation_id: Identifier | None = None
54
+ parent_event_id: Identifier | None = None
55
+
56
+
57
+ Label = Annotated[
58
+ str,
59
+ Field(pattern=r"^[A-Za-z0-9_.:/-]{1,128}$"),
60
+ AfterValidator(_reject_credential_shaped),
61
+ ]
62
+
63
+
64
+ class Decision(StrEnum):
65
+ """Policy decision values shared by Python callers and the JSON contract."""
66
+
67
+ ALLOW = "allow"
68
+ DENY = "deny"
69
+ APPROVAL_REQUIRED = "approval_required"
70
+ UNDECIDED = "undecided"
71
+
72
+
73
+ class StructuralPayload(ClosedModel):
74
+ """Optional conventions eligible for structural-only export, not required fields."""
75
+
76
+ tool_name: Label | None = None
77
+ tool_call_id: Identifier | None = None
78
+ target: Label | None = None
79
+ operation: Label | None = None
80
+ duration_ms: Annotated[float, Field(ge=0)] | None = None
81
+ outcome: Literal["completed", "failed", "blocked", "unknown"] | None = None
82
+ decision: Decision | None = Field(default=None, strict=False)
83
+ decision_id: Identifier | None = None
84
+ policy_version: Identifier | None = None
85
+ provenance: Literal["observed", "asserted", "inferred", "unknown"] | None = None
86
+
87
+
88
+ def _validate_payload(value: dict[str, JsonValue]) -> dict[str, JsonValue]:
89
+ """Bound arbitrary JSON and reject known secrets; this does not anonymize content."""
90
+ pending: list[tuple[JsonValue, int]] = [(value, 0)]
91
+ count = 0
92
+ while pending:
93
+ item, depth = pending.pop()
94
+ count += 1
95
+ if depth > 8 or count > 2048:
96
+ raise ValueError("payload exceeds structural limits")
97
+ if isinstance(item, dict):
98
+ for key, child in item.items():
99
+ if len(key) > 128 or is_secret_key_name(key):
100
+ raise ValueError("payload contains a prohibited key")
101
+ _reject_credential_shaped(key)
102
+ pending.append((child, depth + 1))
103
+ elif isinstance(item, list):
104
+ pending.extend((child, depth + 1) for child in item)
105
+ elif isinstance(item, str):
106
+ _reject_credential_shaped(item)
107
+ try:
108
+ encoded = json.dumps(value, ensure_ascii=True, allow_nan=False, separators=(",", ":"))
109
+ except (ValueError, OverflowError) as exc:
110
+ raise ValueError("payload must contain finite JSON values") from exc
111
+ if len(encoded) > 16384:
112
+ raise ValueError("payload exceeds 16 KiB")
113
+ return value
114
+
115
+
116
+ class Observation(ClosedModel):
117
+ version: Literal[1]
118
+ event_id: Identifier
119
+ type: Label
120
+ timestamp: Timestamp
121
+ context: EventContext | None = None
122
+ payload: Annotated[dict[str, JsonValue], AfterValidator(_validate_payload)] | None = None
123
+
124
+ @property
125
+ def workflow_id(self) -> str:
126
+ # No invented workflow/session correlation when the producer supplies none.
127
+ return (self.context.correlation_id if self.context else None) or self.event_id
128
+
129
+
130
+ class ObserveRequest(ClosedModel):
131
+ mode: Literal["observe"]
132
+ source_id: Identifier
133
+ events: Annotated[list[Observation], Field(min_length=1, max_length=64)]
134
+
135
+
136
+ class DecisionRequest(ClosedModel):
137
+ """Contract for future decide mode; the current service rejects this mode."""
138
+
139
+ mode: Literal["decide"]
140
+ source_id: Identifier
141
+ timeout_ms: Annotated[int, Field(ge=1, le=60000)]
142
+ event: Observation
143
+ context_events: Annotated[list[Observation], Field(max_length=64)] = Field(default_factory=list)
144
+
145
+
146
+ class DecisionResponse(ClosedModel):
147
+ event_id: Identifier
148
+ decision_id: Identifier
149
+ decision: Decision = Field(strict=False)
150
+ policy_version: Identifier
151
+ matched_rules: Annotated[list[Identifier], Field(max_length=64)] = Field(default_factory=list)
152
+ reason: Annotated[str, Field(max_length=1024)] | None = None
153
+
154
+
155
+ class WorkflowProjection(TypedDict):
156
+ events: list[dict[str, Any]]
157
+ missing_parents: list[str]
158
+ coverage: str
159
+
160
+
161
+ def _view(event: Observation) -> dict[str, Any]:
162
+ """Derived UI fields do not widen the event's wire envelope."""
163
+ payload = event.payload or {}
164
+ context = event.context
165
+ decision = payload.get("decision")
166
+ outcome = payload.get("outcome") if event.type == "tool.result" else None
167
+ provenance = payload.get("provenance")
168
+ return {
169
+ **event.model_dump(mode="json"),
170
+ "actor": (context.agent_id if context else None) or "unknown",
171
+ "target": next(
172
+ (payload[key] for key in ("tool_name", "target") if isinstance(payload.get(key), str)),
173
+ "unknown",
174
+ ),
175
+ "action": event.type,
176
+ "occurred_at": event.timestamp,
177
+ "parent_event_id": context.parent_event_id if context else None,
178
+ "decision": decision
179
+ if isinstance(decision, str) and decision in {value.value for value in Decision}
180
+ else "not_reported",
181
+ "outcome": outcome
182
+ if isinstance(outcome, str) and outcome in {"completed", "failed", "blocked"}
183
+ else "unknown",
184
+ "provenance": provenance
185
+ if isinstance(provenance, str) and provenance in {"observed", "asserted", "inferred"}
186
+ else "unknown",
187
+ }
188
+
189
+
190
+ def project_workflow(events: list[Observation]) -> WorkflowProjection:
191
+ """Timestamp ordering is presentation, not causality or delivery completeness.
192
+
193
+ `events` must come from one authenticated source. `Observation` carries no
194
+ `source_id` -- by design (ADR-0120), source identity lives at the request/
195
+ authenticated-API boundary (`ObserveRequest.source_id`), never inside the
196
+ event envelope -- so this function has no way to tell two sources' events
197
+ apart if a caller merges them before calling it. Cross-source stitching is
198
+ explicitly deferred (docs/specs/production-agent-observability.md); callers
199
+ must partition by source before projecting, the same way every caller in
200
+ this repository already does (one `ObserveRequest`'s `events` per call).
201
+ """
202
+ ordered = sorted(events, key=lambda e: (datetime.fromisoformat(e.timestamp), e.event_id))
203
+ # Scoped per workflow_id: an event_id from one workflow must not resolve a
204
+ # parent_event_id in an unrelated one, matching "parent references resolve
205
+ # only within that source/workflow" (docs/specs/production-agent-observability.md).
206
+ ids_by_workflow: dict[str, set[str]] = {}
207
+ for event in events:
208
+ ids_by_workflow.setdefault(event.workflow_id, set()).add(event.event_id)
209
+ missing = sorted(
210
+ {
211
+ e.context.parent_event_id
212
+ for e in events
213
+ if e.context
214
+ and e.context.parent_event_id
215
+ and e.context.parent_event_id not in ids_by_workflow.get(e.workflow_id, set())
216
+ }
217
+ )
218
+ return {
219
+ "events": [_view(event) for event in ordered],
220
+ "missing_parents": missing,
221
+ "coverage": "partial" if missing else "reported_only",
222
+ }
@@ -36,9 +36,10 @@ from importlib.metadata import (
36
36
  from pathlib import Path
37
37
  from typing import Any
38
38
 
39
- from stacktrace_cli.correlate.composition import (
39
+ from stacktrace.correlate.composition import (
40
40
  Advisory,
41
41
  Composition,
42
+ composable,
42
43
  composition_from_bom,
43
44
  reject_malformed_shape,
44
45
  root_property,
@@ -234,8 +235,13 @@ def build_bom(
234
235
 
235
236
  Returns None rather than raising when nothing was produced: a machine can run
236
237
  one agent kind and not another, which is a fact about the machine rather than
237
- a failure to look.
238
+ a failure to look. A kind OpenACA does not know is the same answer for the
239
+ same reason -- sessions can come from an agent whose setup OpenACA cannot
240
+ describe yet, and those sessions resolve as having no composition rather than
241
+ failing every other kind's run.
238
242
  """
243
+ if not composable(agent_kind):
244
+ return None
239
245
  with tempfile.TemporaryDirectory() as scratch:
240
246
  directory = Path(scratch)
241
247
  argv = [