stacktrace-cli 0.12.0__py3-none-any.whl → 0.14.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 (65) hide show
  1. stacktrace/__init__.py +1 -1
  2. stacktrace/cli.py +14 -8
  3. stacktrace/cloud/__init__.py +12 -35
  4. stacktrace/cloud/boot.py +186 -0
  5. stacktrace/cloud/profile.py +71 -0
  6. stacktrace/cloud/setup.sh +27 -311
  7. stacktrace/correlate/composition.py +22 -1
  8. stacktrace/cost/measures.py +2 -2
  9. stacktrace/cost/models.py +12 -0
  10. stacktrace/cost/rules.py +3 -193
  11. stacktrace/cost/usage.py +55 -0
  12. stacktrace/cost/weights.py +3 -8
  13. stacktrace/daemon/client.py +10 -4
  14. stacktrace/daemon/config.py +0 -42
  15. stacktrace/daemon/costs.py +5 -13
  16. stacktrace/daemon/db.py +45 -0
  17. stacktrace/daemon/fleet.py +34 -9
  18. stacktrace/daemon/policy_collect.py +346 -0
  19. stacktrace/daemon/policy_inputs.py +278 -0
  20. stacktrace/daemon/policy_runtime.py +597 -0
  21. stacktrace/daemon/presentation.py +22 -7
  22. stacktrace/daemon/protocol.py +46 -2
  23. stacktrace/daemon/runtime.py +30 -12
  24. stacktrace/daemon/server.py +76 -2
  25. stacktrace/daemon/store.py +61 -5
  26. stacktrace/daemon/usage.py +13 -17
  27. stacktrace/daemon/webhook.py +251 -23
  28. stacktrace/detector/cache.py +2 -8
  29. stacktrace/detector/deterministic.py +813 -568
  30. stacktrace/detector/finding.py +35 -6
  31. stacktrace/detector/history.py +4 -13
  32. stacktrace/detector/jev.py +1 -14
  33. stacktrace/detector/personal_data.py +328 -0
  34. stacktrace/detector/reasoning.py +1 -9
  35. stacktrace/detector/render.py +10 -0
  36. stacktrace/detector/rules.py +94 -25
  37. stacktrace/detector/run.py +3 -0
  38. stacktrace/detector/secrets.py +88 -3
  39. stacktrace/finding_contract.py +553 -0
  40. stacktrace/finding_titles.py +46 -0
  41. stacktrace/hooks/__init__.py +1 -0
  42. stacktrace/hooks/claude.py +242 -0
  43. stacktrace/hooks/codex.py +84 -0
  44. stacktrace/hooks/session.py +76 -0
  45. stacktrace/monitor/costs.py +4 -4
  46. stacktrace/monitor/render.py +11 -5
  47. stacktrace/monitor/site/app.js +57 -1
  48. stacktrace/monitor/site/cost.js +16 -9
  49. stacktrace/monitor/site/styles.css +2 -0
  50. stacktrace/monitor/verdicts.py +26 -9
  51. stacktrace/policy_display.py +129 -0
  52. stacktrace/remote/cli.py +8 -44
  53. stacktrace/remote/client.py +159 -15
  54. stacktrace/remote/config.py +61 -2
  55. stacktrace/remote/detect_payload.py +55 -4
  56. stacktrace/remote/hook_policy.py +193 -0
  57. stacktrace/remote/policy_service.py +0 -21
  58. stacktrace/remote/policy_show.py +119 -0
  59. stacktrace/remote/sync.py +14 -2
  60. stacktrace/remote/upload_contract.py +40 -0
  61. stacktrace/telemetry/events.py +1 -0
  62. {stacktrace_cli-0.12.0.dist-info → stacktrace_cli-0.14.0.dist-info}/METADATA +12 -7
  63. {stacktrace_cli-0.12.0.dist-info → stacktrace_cli-0.14.0.dist-info}/RECORD +65 -50
  64. {stacktrace_cli-0.12.0.dist-info → stacktrace_cli-0.14.0.dist-info}/WHEEL +0 -0
  65. {stacktrace_cli-0.12.0.dist-info → stacktrace_cli-0.14.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.12.0"
5
+ __version__ = "0.14.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
stacktrace/cli.py CHANGED
@@ -17,7 +17,6 @@ from __future__ import annotations
17
17
 
18
18
  import re
19
19
  from datetime import UTC, datetime, timedelta
20
- from importlib.metadata import version
21
20
  from pathlib import Path
22
21
  from typing import Final
23
22
 
@@ -120,7 +119,7 @@ def _show_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
120
119
  return
121
120
  # A build from a branch or PR reports its commit as a PEP 440 local label,
122
121
  # `0.4.0+86eef86`; a release install prints the bare version.
123
- click.echo(f"stacktrace {version_label()} (openaca {version('openaca')})")
122
+ click.echo(f"stacktrace {version_label()}")
124
123
  ctx.exit()
125
124
 
126
125
 
@@ -195,11 +194,11 @@ class SectionedGroup(click.Group):
195
194
  raise
196
195
  except (click.exceptions.Exit, click.ClickException) as error:
197
196
  cause = error.__cause__
198
- if cause is not None and ctx.invoked_subcommand not in UNCOUNTED:
197
+ if cause is not None and not _uncounted(ctx):
199
198
  emit_error(cause, "cli")
200
199
  raise
201
200
  except Exception as error:
202
- if ctx.invoked_subcommand not in UNCOUNTED:
201
+ if not _uncounted(ctx):
203
202
  emit_error(error, "cli")
204
203
  raise
205
204
  _count_run(ctx)
@@ -232,9 +231,15 @@ class SectionedGroup(click.Group):
232
231
  formatter.write_dl(rows)
233
232
 
234
233
 
234
+ def _uncounted(ctx: click.Context) -> bool:
235
+ # An internal synchronous native hook is not a developer command and must
236
+ # never initialize an analytics client on the action path (ADR-0131).
237
+ return ctx.invoked_subcommand in UNCOUNTED or ctx.meta.get("internal_admission") is True
238
+
239
+
235
240
  def _count_run(ctx: click.Context) -> None:
236
241
  name = ctx.invoked_subcommand
237
- if name not in UNCOUNTED:
242
+ if not _uncounted(ctx):
238
243
  emit("command_run", command=command_of(name))
239
244
 
240
245
 
@@ -245,7 +250,7 @@ def _count_run(ctx: click.Context) -> None:
245
250
  expose_value=False,
246
251
  is_eager=True,
247
252
  callback=_show_version,
248
- help="Show the stacktrace and OpenACA versions (and build commit) and exit.",
253
+ help="Show the stacktrace version (and build commit) and exit.",
249
254
  )
250
255
  def main() -> None:
251
256
  """Stacktrace: agent composition analysis, detection and upload."""
@@ -431,8 +436,9 @@ def detect(
431
436
  One network call is made on every run, and it is not the detector's:
432
437
  correlation matches advisories by sending the package coordinates of the
433
438
  components a session invoked to osv.dev. Coordinates only, so this is not
434
- an offline run, and without stage three it is the only thing that leaves
435
- this machine.
439
+ an offline run, and without stage three it is the only thing detection
440
+ sends. Usage telemetry, on by default, is separate and carries no session
441
+ content (ADR-0039); `stacktrace telemetry show` prints all of it.
436
442
  """
437
443
  analyzer = resolve_analyzer(analyzer, reasoning)
438
444
  try:
@@ -1,38 +1,10 @@
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, 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.
8
-
9
- Rather than paste that into the setup-script box, a setup script installs the
10
- CLI and runs this:
11
-
12
- UV_TOOL_DIR=/usr/local/share/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin \
13
- uv tool install stacktrace-cli
14
- stacktrace-cloud-setup
15
-
16
- Or, equivalently, one hosted line that wraps exactly those two steps (see
17
- `stacktrace-site`'s `public/remote-install.sh`). Download before running rather
18
- than piping directly into `sh`: without `pipefail` (the default in the `sh`
19
- that a setup-script box runs), a failed `curl` still lets `sh` read an empty
20
- script and exit 0, so a broken download would otherwise snapshot an
21
- environment with neither the CLI nor the boot hook and report success:
22
-
23
- curl -fsSL https://stacktrace.ai/remote-install.sh -o /tmp/remote-install.sh \
24
- && sh /tmp/remote-install.sh
25
-
26
- Both variables matter: setup runs as root, and `uv tool install`'s root
27
- defaults put the tool's environment under `/root/.local/share/uv/tools` and the
28
- linked executable under `/root/.local/bin` — both inside `/root`, which a later
29
- non-root session user cannot traverse. `UV_TOOL_BIN_DIR` alone moves the
30
- symlink but not the environment it points into, so `command -v stacktrace` would
31
- find the binary while running it still fails; both must move.
32
-
33
- The work itself is `setup.sh`, shipped beside this module so it travels in the
34
- wheel. This shim only locates it and hands off, so the logic stays one auditable
35
- bash file rather than being reimplemented in Python.
1
+ """Prepare plugin-owned hooks and asset identity from a remote startup script.
2
+
3
+ The shared installer calls `stacktrace-cloud-setup --user-profile` as the
4
+ selected user. This prepares identity and update metadata without system
5
+ writes. Legacy provisioning retains asset and update records for existing
6
+ recipes. Agent installation belongs to the shared installer.
7
+ SessionStart restores the daemon after process loss. See ADR-0143.
36
8
  """
37
9
 
38
10
  from __future__ import annotations
@@ -53,6 +25,11 @@ from pathlib import Path
53
25
 
54
26
 
55
27
  def main() -> None:
28
+ if "--user-profile" in sys.argv[1:]:
29
+ from stacktrace.cloud.profile import main as prepare_profile
30
+
31
+ prepare_profile(sys.argv[1:])
32
+ return
56
33
  script = Path(__file__).with_name("setup.sh")
57
34
  # execvp so the process *becomes* bash: its exit status is the setup
58
35
  # script's, which is what the environment reads to decide the session may
@@ -0,0 +1,186 @@
1
+ """Recover the shared daemon on every host (ADR-0143). Remote recovery can
2
+ update the client before startup (ADR-0123). The SessionStart adapter runs
3
+ this worker detached with the state directory and installer update record.
4
+
5
+ One worker at a time holds `boot.lock`, from its own `daemon status` check
6
+ until the daemon it starts answers. So a worker never replaces the client's
7
+ files while a daemon runs or is starting: a second hook's worker waits, checks
8
+ again, and finds the daemon up.
9
+ """
10
+
11
+ import fcntl
12
+ import os
13
+ import pwd
14
+ import shlex
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from datetime import datetime, timezone
19
+
20
+ STATE, UPDATE_ENV = "", ""
21
+ #: How long an update may take before the installed client runs instead.
22
+ UPDATE_SECONDS = 120
23
+ #: How long the lock waits for the daemon this worker started to answer.
24
+ START_SECONDS = 30
25
+
26
+
27
+ def note(text):
28
+ with open(os.path.join(STATE, "update.log"), "a", encoding="utf-8") as log:
29
+ log.write(f"{datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ} {text}\n") # noqa: UP017 - legacy wrapper uses system Python, including 3.10
30
+
31
+
32
+ def running():
33
+ """What `daemon status` says: yes, no, unresponsive, or "" for no answer."""
34
+ try:
35
+ status = subprocess.run(
36
+ ["stacktrace", "daemon", "status"],
37
+ capture_output=True,
38
+ text=True,
39
+ timeout=30,
40
+ check=False,
41
+ ).stdout
42
+ except (OSError, subprocess.SubprocessError):
43
+ return ""
44
+ for line in status.splitlines():
45
+ if line.startswith("running: "):
46
+ return line[len("running: ") :].strip()
47
+ return ""
48
+
49
+
50
+ def recorded():
51
+ """The uv and the UV_* directories setup wrote, as `KEY=value` lines."""
52
+ try:
53
+ with open(UPDATE_ENV, encoding="utf-8") as stream:
54
+ lines = stream.read().splitlines()
55
+ except OSError:
56
+ return {}
57
+ values = {}
58
+ for line in lines:
59
+ words = shlex.split(line)
60
+ if words[:1] == ["export"]:
61
+ words = words[1:]
62
+ if len(words) == 1 and "=" in words[0]:
63
+ key, value = words[0].split("=", 1)
64
+ values[key] = value
65
+ return values
66
+
67
+
68
+ def update():
69
+ """Upgrade the tool install setup recorded, as a user who can write it;
70
+ else say why not. No file: the client is pinned, or setup found no uv."""
71
+ values = recorded()
72
+ if not values:
73
+ return
74
+ uv = values.get("STACKTRACE_UV", "")
75
+ directories = [values[key] for key in ("UV_TOOL_DIR", "UV_TOOL_BIN_DIR") if values.get(key)]
76
+ if not (uv and os.access(uv, os.X_OK)) or not all(os.access(d, os.W_OK) for d in directories):
77
+ user = pwd.getpwuid(os.getuid()).pw_name
78
+ note(
79
+ f"not updating: {user} cannot run uv or write the install; "
80
+ "rebuild the environment to update the client"
81
+ )
82
+ return
83
+ environment = {**os.environ, **{k: v for k, v in values.items() if k.startswith("UV_")}}
84
+ with open(os.path.join(STATE, "update.log"), "a", encoding="utf-8") as log:
85
+ try:
86
+ subprocess.run(
87
+ [uv, "tool", "upgrade", "stacktrace-cli"],
88
+ env=environment,
89
+ stdin=subprocess.DEVNULL,
90
+ stdout=log,
91
+ stderr=subprocess.STDOUT,
92
+ timeout=UPDATE_SECONDS,
93
+ check=False,
94
+ )
95
+ except (OSError, subprocess.SubprocessError) as error:
96
+ note(f"update stopped: {type(error).__name__}; the installed client runs")
97
+
98
+
99
+ def configure():
100
+ """Session credentials belong to this user; subscribers fail independently."""
101
+ commands = []
102
+ if os.environ.get("STACKTRACE_REMOTE_TOKEN"):
103
+ # Click reads credentials from the environment, not the process list.
104
+ commands.append(["stacktrace", "remote", "configure"])
105
+ webhook = os.environ.get("STACKTRACE_SLACK_WEBHOOK_URL")
106
+ if webhook:
107
+ commands.append(
108
+ [
109
+ "stacktrace",
110
+ "webhook",
111
+ "configure",
112
+ "--url",
113
+ webhook,
114
+ "--secret",
115
+ os.environ.get("STACKTRACE_WEBHOOK_SECRET", ""),
116
+ ]
117
+ )
118
+ for arguments in commands:
119
+ try:
120
+ result = subprocess.run(
121
+ arguments,
122
+ stdin=subprocess.DEVNULL,
123
+ stdout=subprocess.DEVNULL,
124
+ stderr=subprocess.DEVNULL,
125
+ timeout=30,
126
+ check=False,
127
+ )
128
+ if result.returncode:
129
+ note("session configuration failed; using saved configuration")
130
+ except (OSError, subprocess.SubprocessError):
131
+ note("session configuration unavailable; using saved configuration")
132
+
133
+
134
+ def ensure_daemon():
135
+ lock = os.open(os.path.join(STATE, "boot.lock"), os.O_CREAT | os.O_RDWR, 0o600)
136
+ try:
137
+ fcntl.flock(lock, fcntl.LOCK_EX)
138
+ configure()
139
+ _ensure_daemon()
140
+ finally:
141
+ os.close(lock)
142
+
143
+
144
+ def _ensure_daemon():
145
+ answer = running()
146
+ if answer == "yes":
147
+ return
148
+ # Only a status of no daemon lets the update run: `unresponsive` is a
149
+ # daemon holding its lock that has not answered yet, and an error or no
150
+ # answer says nothing. Either may be a live client.
151
+ if answer == "no":
152
+ if os.environ.get("CLAUDE_CODE_REMOTE") == "true":
153
+ update()
154
+ else:
155
+ note(f"not updating: daemon status said {answer or 'nothing'}, not that no daemon runs")
156
+ with open(os.path.join(STATE, "daemon.log"), "a", encoding="utf-8") as out:
157
+ subprocess.Popen(
158
+ ["stacktrace", "daemon", "start", "--no-reasoning"],
159
+ stdin=subprocess.DEVNULL,
160
+ stdout=out,
161
+ stderr=subprocess.STDOUT,
162
+ start_new_session=True,
163
+ )
164
+ deadline = time.monotonic() + START_SECONDS
165
+ while time.monotonic() < deadline and running() != "yes":
166
+ time.sleep(0.5)
167
+
168
+
169
+ def main():
170
+ os.makedirs(STATE, mode=0o700, exist_ok=True)
171
+ os.chmod(STATE, 0o700)
172
+ ensure_daemon()
173
+ if len(sys.argv) > 3:
174
+ import json
175
+
176
+ from stacktrace.hooks.session import register
177
+
178
+ register(json.loads(sys.argv[3]))
179
+
180
+
181
+ if __name__ == "__main__":
182
+ STATE, UPDATE_ENV = sys.argv[1], sys.argv[2]
183
+ try:
184
+ main()
185
+ except Exception: # noqa: BLE001 - never expose native payloads or credentials
186
+ note("session bootstrap failed; inspect daemon status and rerun setup")
@@ -0,0 +1,71 @@
1
+ """Prepare remote state as the selected user, without privileged system files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import tempfile
10
+ import uuid
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+
14
+ from stacktrace.daemon.paths import RuntimePaths
15
+ from stacktrace.private_state import ensure_private_directory
16
+
17
+
18
+ def _write(path: Path, value: str) -> None:
19
+ descriptor, name = tempfile.mkstemp(dir=path.parent)
20
+ try:
21
+ with os.fdopen(descriptor, "w") as stream:
22
+ stream.write(value)
23
+ os.replace(name, path)
24
+ finally:
25
+ Path(name).unlink(missing_ok=True)
26
+
27
+
28
+ def prepare(environment: Mapping[str, str]) -> None:
29
+ config = Path.home() / ".config" / "stacktrace"
30
+ ensure_private_directory(config)
31
+ identity = config / "asset-id"
32
+ imposed = environment.get("STACKTRACE_ASSET_EXTERNAL_ID", "")
33
+ if imposed and (
34
+ len(imposed) > 255 or not imposed.isprintable() or any(c.isspace() for c in imposed)
35
+ ):
36
+ raise ValueError(
37
+ "asset id must contain at most 255 printable characters without whitespace"
38
+ )
39
+ if imposed or not identity.exists():
40
+ _write(identity, (imposed or str(uuid.uuid4())) + "\n")
41
+ _write(config / "asset-display-prefix", "cloud\n")
42
+
43
+ state = RuntimePaths.from_environment().state.parent
44
+ ensure_private_directory(state)
45
+ uv = shutil.which("uv")
46
+ values = {}
47
+ if uv and environment.get("STACKTRACE_VERSION", "latest") in ("", "latest"):
48
+ values["STACKTRACE_UV"] = uv
49
+ for key in (
50
+ "UV_TOOL_DIR",
51
+ "UV_TOOL_BIN_DIR",
52
+ "UV_PYTHON_INSTALL_DIR",
53
+ "UV_PYTHON_PREFERENCE",
54
+ ):
55
+ if environment.get(key):
56
+ values[key] = environment[key]
57
+ # Keep an empty record for a pin. Removing it would enable the legacy fallback.
58
+ _write(
59
+ state / "install-update.env", "".join(f"{k}={shlex.quote(v)}\n" for k, v in values.items())
60
+ )
61
+
62
+
63
+ def main(arguments: list[str]) -> None:
64
+ parser = argparse.ArgumentParser(description=__doc__)
65
+ parser.add_argument("--user-profile", action="store_true", required=True)
66
+ parser.add_argument("--asset-id")
67
+ options = parser.parse_args(arguments)
68
+ environment = dict(os.environ)
69
+ if options.asset_id is not None:
70
+ environment["STACKTRACE_ASSET_EXTERNAL_ID"] = options.asset_id
71
+ prepare(environment)