stacktrace-cli 0.7.0__py3-none-any.whl → 0.7.1__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.7.0"
5
+ __version__ = "0.7.1"
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
@@ -210,14 +210,13 @@ def _reject_unobservable_kinds(config: DaemonConfig) -> None:
210
210
 
211
211
 
212
212
  @daemon.command()
213
- def status() -> None:
213
+ @click.option("--verbose", is_flag=True, help="Include detailed daemon diagnostics.")
214
+ def status(verbose: bool) -> None:
214
215
  """Report whether this endpoint is working, without reading a log.
215
216
 
216
- Every row answers a question someone asks when a machine looks quiet: a
217
- watermark hours old means the scanner has stopped; a queue that grows with
218
- a reader attached means the reader is not draining; a database that only
219
- grows means retention is not pruning; and a configuration without its source
220
- makes fleet-wide drift undebuggable (`docs/specs/daemon-scanning.md`).
217
+ The default output is a quick health check. Pass ``--verbose`` for the
218
+ diagnostic details used to investigate a quiet or misconfigured machine
219
+ (`docs/specs/daemon-scanning.md`).
221
220
  """
222
221
  from stacktrace_cli.build import version_label
223
222
 
@@ -242,7 +241,8 @@ def status() -> None:
242
241
  if live is not None:
243
242
  click.echo(f"version: {live.build}")
244
243
  click.echo(f"installed: {version_label()}")
245
- click.echo(f"started: {live.started_at}")
244
+ if verbose:
245
+ click.echo(f"started: {live.started_at}")
246
246
 
247
247
  try:
248
248
  # One `_store()` call apiece, all against the same file: if the first
@@ -251,9 +251,10 @@ def status() -> None:
251
251
  # them can print a partial line before that first failure.
252
252
  click.echo(f"watermark: {_watermark_line(paths)}")
253
253
  click.echo(f"queue depth: {_queue_line(paths)}")
254
- click.echo(f"cursors: {_cursor_line(paths)}")
255
254
  click.echo(f"parked: {_parked_line(paths)}")
256
- click.echo(f"database: {_database_line(paths)}")
255
+ if verbose:
256
+ click.echo(f"cursors: {_cursor_line(paths)}")
257
+ click.echo(f"database: {_database_line(paths)}")
257
258
  except (sqlite3.DatabaseError, ValueError) as exc:
258
259
  # A corrupt file or a schema newer than this build reads raises here
259
260
  # (`_check_version`'s own refusal) -- precisely the moment the daemon
@@ -263,6 +264,9 @@ def status() -> None:
263
264
  click.echo(f"database: unreadable — {exc}")
264
265
  click.echo(f"last error: {last_error(paths.log) or 'none reported'}")
265
266
 
267
+ if not verbose:
268
+ return
269
+
266
270
  try:
267
271
  config = _effective_config(live.config if live is not None else None)
268
272
  except ValueError as exc:
@@ -43,6 +43,10 @@ _ASSETS: dict[str, str] = {
43
43
  "/app.js": "app.js",
44
44
  "/styles.css": "styles.css",
45
45
  "/inventory.js": "inventory.js",
46
+ # The same icons Fleet's web app ships, copied rather than shared.
47
+ "/favicon.ico": "favicon.ico",
48
+ "/icon.svg": "icon.svg",
49
+ "/apple-icon.png": "apple-icon.png",
46
50
  # Vendored rather than fetched, so they are served rather than linked. An
47
51
  # allow-list of exact names is why they have to be named here: a font
48
52
  # missing from it 404s and the page falls back silently, which is the
@@ -57,6 +61,9 @@ _CONTENT_TYPES: dict[str, str] = {
57
61
  "app.js": "text/javascript; charset=utf-8",
58
62
  "styles.css": "text/css; charset=utf-8",
59
63
  "inventory.js": "text/javascript; charset=utf-8",
64
+ "favicon.ico": "image/x-icon",
65
+ "icon.svg": "image/svg+xml",
66
+ "apple-icon.png": "image/png",
60
67
  "fonts/dm-sans-latin.woff2": "font/woff2",
61
68
  "fonts/dm-mono-400-latin.woff2": "font/woff2",
62
69
  "fonts/dm-mono-500-latin.woff2": "font/woff2",
Binary file
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
2
+ <rect width="48" height="48" rx="11" fill="#17130e"></rect>
3
+ <g transform="translate(24 24) scale(0.72) translate(-24 -24)"><path d="M38 8H18A8.5 8.5 0 0 0 18 25H20.5" stroke="#ede3d0" stroke-width="5" stroke-linecap="round" fill="none"></path>
4
+ <path d="M27.5 25H30A8.5 8.5 0 0 1 30 42H12" stroke="#ede3d0" stroke-width="5" stroke-linecap="round" fill="none"></path>
5
+ <circle cx="38" cy="8" r="5" fill="#e8681f"></circle>
6
+ <circle cx="12" cy="42" r="5" fill="#e8681f"></circle></g>
7
+ </svg>
@@ -4,6 +4,9 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>Stacktrace — monitor</title>
7
+ <link rel="icon" href="favicon.ico" sizes="any">
8
+ <link rel="icon" href="icon.svg" type="image/svg+xml">
9
+ <link rel="apple-touch-icon" href="apple-icon.png">
7
10
  <link rel="stylesheet" href="styles.css">
8
11
  </head>
9
12
  <body>
@@ -15,12 +15,17 @@ openaca's is copied into this repository — not copying it is the point.
15
15
  from __future__ import annotations
16
16
 
17
17
  import json
18
+ import os
19
+ import pwd
20
+ import re
21
+ import sys
18
22
  from pathlib import Path
19
23
 
20
24
  import click
21
25
  import httpx
22
26
  from openaca.core import KindSelectionError, validate_kind_selection
23
27
 
28
+ from stacktrace_cli.build import version_label
24
29
  from stacktrace_cli.detector.run import DEFAULT_BUDGET, DEFAULT_SAMPLE_BUDGET
25
30
  from stacktrace_cli.options import analyzer_option, resolve_analyzer
26
31
  from stacktrace_cli.remote.client import (
@@ -39,6 +44,7 @@ from stacktrace_cli.remote.config import (
39
44
  from stacktrace_cli.remote.policy import compile_remote_policy
40
45
  from stacktrace_cli.remote.policy_service import (
41
46
  ApplyResult,
47
+ EnrollmentReplacementRequired,
42
48
  PolicyServiceError,
43
49
  default_policy_service,
44
50
  )
@@ -111,7 +117,11 @@ def configure(token: str, api_url: str) -> None:
111
117
  # asset id are still the ones in effect. Purging first would delete
112
118
  # the backlog they need before the switch away from them ever lands.
113
119
  save_remote_config(
114
- RemoteConfig(api_url=api_url, token=token, asset_id=preserved_asset_id),
120
+ RemoteConfig(
121
+ api_url=api_url,
122
+ token=token,
123
+ asset_id=preserved_asset_id,
124
+ ),
115
125
  config_path,
116
126
  )
117
127
  if preserved_asset_id is None and existing.asset_id is not None:
@@ -119,11 +129,51 @@ def configure(token: str, api_url: str) -> None:
119
129
  except (ConfigError, OSError) as exc:
120
130
  raise click.ClickException(str(exc)) from exc
121
131
  click.echo(f"Remote configured at {api_url} with token {_mask_token(token)}")
132
+ _update_policy_enrollment(api_url)
133
+
134
+
135
+ def _update_policy_enrollment(api_url: str) -> None:
136
+ if not _stdin_is_tty():
137
+ return
138
+ runtime_version = _policy_runtime_version()
139
+ if runtime_version is None:
140
+ click.echo(
141
+ "Automatic policy sync is not installed; run https://stacktrace.ai/install.sh "
142
+ "to enable it"
143
+ )
144
+ return
145
+ if runtime_version != version_label():
146
+ click.echo(
147
+ f"Automatic policy sync uses stacktrace {runtime_version}; "
148
+ "rerun https://stacktrace.ai/install.sh to upgrade it"
149
+ )
150
+ return
151
+ click.echo("Updating automatic organization policy sync (administrator approval required)...")
152
+ try:
153
+ _install_policy_enrollment(user=_current_user(), api_url=api_url)
154
+ except PolicyServiceError as exc:
155
+ raise click.ClickException("automatic policy sync update failed") from exc
156
+
157
+
158
+ def _policy_runtime_version() -> str | None:
159
+ return default_policy_service().runtime_version()
160
+
161
+
162
+ def _install_policy_enrollment(*, user: str, api_url: str) -> None:
163
+ default_policy_service().install_via_sudo(user=user, expected_api_url=api_url)
164
+
165
+
166
+ def _stdin_is_tty() -> bool:
167
+ return sys.stdin.isatty()
168
+
169
+
170
+ def _current_user() -> str:
171
+ return pwd.getpwuid(os.getuid()).pw_name
122
172
 
123
173
 
124
174
  @main.command()
125
175
  def status() -> None:
126
- """Show remote token and asset status."""
176
+ """Show remote connection and local asset registration."""
127
177
  try:
128
178
  config = load_remote_config(get_config_path())
129
179
  except ConfigError as exc:
@@ -134,29 +184,22 @@ def status() -> None:
134
184
  client = RemoteClient(api_url=config.api_url, token=config.token)
135
185
  try:
136
186
  me = client.get_me()
137
- click.echo(f"Org: {me.org.name} ({me.org.id})")
138
- click.echo(f"Token: {me.token.name} ({me.token.id})")
139
- if config.asset_id is None:
140
- click.echo(NO_ASSET_YET)
141
- return
142
- asset = client.get_asset(config.asset_id)
143
187
  except httpx.TransportError as exc:
144
188
  raise click.ClickException(f"Remote API unreachable: {exc}") from exc
145
189
  except RemoteClientError as exc:
146
190
  raise click.ClickException(str(exc)) from exc
147
191
 
148
- click.echo(f"Asset: {asset.display_name} ({asset.id})")
149
- # Per agent, because an asset has no BOM of its own: one upload per agent
150
- # kind, each with its own id.
151
- if asset.agents:
152
- click.echo("Latest BOM:")
153
- for agent in asset.agents:
154
- coverage = f" ({agent.composition_coverage})" if agent.composition_coverage else ""
155
- click.echo(f" {agent.agent_kind}: {agent.latest_bom_id or 'none'}{coverage}")
192
+ # Printed only once `/me` has accepted the token, so it never claims a
193
+ # connection the server refused.
194
+ click.echo(f"Connected to {config.api_url}")
195
+ click.echo(f"Org: {me.org.name} ({me.org.id})")
196
+ click.echo(f"Token: {me.token.name} ({_mask_token(config.token)})")
197
+ if config.asset_id is None:
198
+ click.echo(NO_ASSET_YET)
156
199
  else:
157
- click.echo("Latest BOM: none — no agent has synced yet")
158
- click.echo(f"Last seen: {asset.last_seen_at or 'never'}")
159
- click.echo(f"Components: {asset.component_count} components")
200
+ # Asset detail is a Clerk-authenticated dashboard surface. The local
201
+ # registration id is the collector-token-safe status this client owns.
202
+ click.echo(f"Asset: {config.asset_id}")
160
203
 
161
204
 
162
205
  @main.group()
@@ -215,11 +258,27 @@ def policy_apply() -> None:
215
258
  @click.option("--api-url", required=True, help="Administrator-confirmed Fleet API origin.")
216
259
  def policy_install_service(user: str, api_url: str) -> None:
217
260
  """Enroll and install automatic privileged policy reconciliation."""
261
+ service = default_policy_service()
218
262
  try:
219
- result = default_policy_service().install(
263
+ result = service.install(
220
264
  user=user,
221
265
  expected_api_url=api_url,
222
266
  )
267
+ except EnrollmentReplacementRequired as exc:
268
+ click.confirm(
269
+ f"Switch automatic policy sync to {_sanitize_for_terminal(exc.new_org_name)}?\n"
270
+ "This removes the previous organization's managed policy",
271
+ abort=True,
272
+ )
273
+ try:
274
+ result = service.install(
275
+ user=user,
276
+ expected_api_url=api_url,
277
+ replace_enrollment=True,
278
+ confirmed_org_id=exc.new_org_id,
279
+ )
280
+ except PolicyServiceError as replacement_exc:
281
+ raise click.ClickException(replacement_exc.code) from replacement_exc
223
282
  except PolicyServiceError as exc:
224
283
  raise click.ClickException(exc.code) from exc
225
284
  if result.action == "no_policy":
@@ -424,6 +483,22 @@ def _print_upload_result(result: BomUploadResult) -> None:
424
483
  click.echo(f"Dashboard: {result.dashboard_url}")
425
484
 
426
485
 
486
+ #: C0 controls and DEL: newline, carriage return, tab, and ESC (the lead byte
487
+ #: of every ANSI escape sequence) among them. A name from Fleet is whoever
488
+ #: holds a token to that org's own word, not this CLI's — stripping these
489
+ #: before it reaches a destructive confirmation prompt is what keeps it from
490
+ #: rewriting or hiding the warning beside it.
491
+ _UNSAFE_TERMINAL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
492
+ _MAX_PROMPT_NAME_LENGTH = 200
493
+
494
+
495
+ def _sanitize_for_terminal(value: str) -> str:
496
+ cleaned = _UNSAFE_TERMINAL_CHARS.sub("", value)
497
+ if len(cleaned) > _MAX_PROMPT_NAME_LENGTH:
498
+ cleaned = cleaned[:_MAX_PROMPT_NAME_LENGTH] + "…"
499
+ return cleaned
500
+
501
+
427
502
  def _mask_token(token: str) -> str:
428
503
  # Prefix + last 4 — the same last-4 the backend stores for console
429
504
  # display — so a user with several tokens can tell which one is
@@ -431,8 +506,8 @@ def _mask_token(token: str) -> str:
431
506
  # reveal little; real tokens are ot_ + 20+ chars.
432
507
  if token.startswith("ot_"):
433
508
  if len(token) >= 23: # ot_ (3) + 20+ real secret chars
434
- return f"ot_...{token[-4:]}"
435
- return "ot_..."
509
+ return f"ot_••••{token[-4:]}"
510
+ return "ot_••••"
436
511
  return "***"
437
512
 
438
513
 
@@ -142,43 +142,6 @@ class PolicyEnvelope:
142
142
  revision: int
143
143
 
144
144
 
145
- @dataclass(frozen=True)
146
- class AgentStatusResult:
147
- """One agent kind composed on an asset.
148
-
149
- Fleet attaches a BOM to an agent rather than to the asset — an endpoint
150
- running Claude Code and Cursor has one of each — so this is what
151
- identifies an upload. The asset carries no BOM id at all.
152
- """
153
-
154
- agent_kind: str
155
- agent_id: str | None
156
- composition_source: str | None
157
- composition_coverage: str | None
158
- latest_bom_id: str | None
159
- last_seen_at: str | None
160
-
161
-
162
- @dataclass(frozen=True)
163
- class AssetStatusResult:
164
- id: str
165
- asset_type: str
166
- external_id: str
167
- display_name: str
168
- owner_clerk_user_id: str | None
169
- team_name: str | None
170
- metadata: JsonObject
171
- last_seen_at: str | None
172
- created_at: str
173
- # Machine-level aggregates stay on the asset; BOM identity does not. An
174
- # asset runs N agent kinds, so "the asset's latest BOM" names nothing —
175
- # which is why Fleet moved it to the agent (its ADR-0018) rather than
176
- # leaving a field that silently meant "whichever kind uploaded last".
177
- component_count: int
178
- # Empty for a machine that has registered but never synced.
179
- agents: tuple[AgentStatusResult, ...] = ()
180
-
181
-
182
145
  class RemoteClient:
183
146
  def __init__(
184
147
  self,
@@ -269,22 +232,6 @@ class RemoteClient:
269
232
  """Compatibility surface for the manual one-shot compiler."""
270
233
  return self.get_policy_envelope().document
271
234
 
272
- def get_asset(self, asset_id: str) -> AssetStatusResult:
273
- data = self._request("GET", f"/api/v1/assets/{asset_id}")
274
- return AssetStatusResult(
275
- id=_required_str(data, "id"),
276
- asset_type=_required_str(data, "asset_type"),
277
- external_id=_required_str(data, "external_id"),
278
- display_name=_required_str(data, "display_name"),
279
- owner_clerk_user_id=_optional_str(data, "owner_clerk_user_id"),
280
- team_name=_optional_str(data, "team_name"),
281
- metadata=_required_object(data, "metadata"),
282
- last_seen_at=_optional_str(data, "last_seen_at"),
283
- created_at=_required_str(data, "created_at"),
284
- component_count=_required_int(data, "component_count"),
285
- agents=_parse_agents(data),
286
- )
287
-
288
235
  def _request(self, method: str, path: str, payload: JsonObject | None = None) -> JsonObject:
289
236
  url = f"{self._api_url}{path}"
290
237
  delays = [1.0, 4.0]
@@ -386,36 +333,6 @@ def _parse_rejected_sessions(data: JsonObject) -> tuple[RejectedSession, ...]:
386
333
  )
387
334
 
388
335
 
389
- def _parse_agents(data: JsonObject) -> tuple[AgentStatusResult, ...]:
390
- """The asset's `agents` array, absent on a backend that predates it.
391
-
392
- A malformed entry is skipped rather than raising: this feeds `remote
393
- status`, a read-only report, and refusing to print an asset because one
394
- agent row is odd is worse than printing the rest.
395
- """
396
- raw = data.get("agents")
397
- if not isinstance(raw, list):
398
- return ()
399
- agents = []
400
- for entry in raw:
401
- if not isinstance(entry, dict):
402
- continue
403
- kind = entry.get("agent_kind")
404
- if not isinstance(kind, str):
405
- continue
406
- agents.append(
407
- AgentStatusResult(
408
- agent_kind=kind,
409
- agent_id=_optional_str(entry, "agent_id"),
410
- composition_source=_optional_str(entry, "composition_source"),
411
- composition_coverage=_optional_str(entry, "composition_coverage"),
412
- latest_bom_id=_optional_str(entry, "latest_bom_id"),
413
- last_seen_at=_optional_str(entry, "last_seen_at"),
414
- )
415
- )
416
- return tuple(agents)
417
-
418
-
419
336
  def _optional_int(data: JsonObject, key: str) -> int | None:
420
337
  value = data.get(key)
421
338
  if value is None:
@@ -64,7 +64,11 @@ def load_remote_config(path: Path | None = None) -> RemoteConfig:
64
64
  raise ConfigError(f"invalid remote config at {config_path}: token must be a string")
65
65
  if asset_id is not None and not isinstance(asset_id, str):
66
66
  raise ConfigError(f"invalid remote config at {config_path}: asset_id must be a string")
67
- return RemoteConfig(api_url=api_url, token=token, asset_id=asset_id)
67
+ return RemoteConfig(
68
+ api_url=api_url,
69
+ token=token,
70
+ asset_id=asset_id,
71
+ )
68
72
 
69
73
 
70
74
  def save_remote_config(config: RemoteConfig, path: Path | None = None) -> None:
@@ -28,7 +28,7 @@ from pathlib import Path
28
28
  from typing import Any, Protocol, cast
29
29
 
30
30
  from stacktrace_cli.remote.client import MeResult, PolicyEnvelope, RemoteClient
31
- from stacktrace_cli.remote.config import ConfigError, load_remote_config
31
+ from stacktrace_cli.remote.config import ConfigError, RemoteConfig, load_remote_config
32
32
 
33
33
  JsonObject = dict[str, Any]
34
34
 
@@ -47,6 +47,22 @@ class PolicyServiceError(Exception):
47
47
  self.code = code
48
48
 
49
49
 
50
+ def _load_enrollment_config(path: Path) -> RemoteConfig:
51
+ try:
52
+ return load_remote_config(path)
53
+ except ConfigError as exc:
54
+ raise PolicyServiceError("enrollment_invalid") from exc
55
+
56
+
57
+ class EnrollmentReplacementRequired(PolicyServiceError):
58
+ """A verified token belongs to another organization on the same host."""
59
+
60
+ def __init__(self, *, new_org_name: str, new_org_id: str) -> None:
61
+ super().__init__("re_enrollment_required")
62
+ self.new_org_name = new_org_name
63
+ self.new_org_id = new_org_id
64
+
65
+
50
66
  @dataclass(frozen=True)
51
67
  class ServicePaths:
52
68
  enrollment: Path
@@ -348,6 +364,8 @@ class PolicyService:
348
364
  *,
349
365
  user: str,
350
366
  expected_api_url: str,
367
+ replace_enrollment: bool = False,
368
+ confirmed_org_id: str | None = None,
351
369
  ) -> ApplyResult:
352
370
  self._require_root()
353
371
  account = self._account_resolver(user)
@@ -356,10 +374,8 @@ class PolicyService:
356
374
  self._paths.runtime.with_name("python"),
357
375
  trusted_uid=self._trusted_uid,
358
376
  )
359
- try:
360
- config = load_remote_config(account.home / ".config" / "stacktrace" / "remote.toml")
361
- except ConfigError as exc:
362
- raise PolicyServiceError("enrollment_invalid") from exc
377
+ config_path = account.home / ".config" / "stacktrace" / "remote.toml"
378
+ config = _load_enrollment_config(config_path)
363
379
  if config.token is None:
364
380
  raise PolicyServiceError("enrollment_missing")
365
381
  if config.api_url.rstrip("/") != expected_api_url.rstrip("/"):
@@ -379,6 +395,16 @@ class PolicyService:
379
395
  groups=account.groups,
380
396
  home=str(account.home),
381
397
  )
398
+ # Validated unconditionally, before any branch on `existing_enrollment`:
399
+ # a confirmed replacement call means the administrator approved this
400
+ # *specific* org from an earlier read. This call re-reads and
401
+ # re-verifies the config and token from scratch, so a `configure`
402
+ # racing the open confirm prompt — to a third org, or back to the one
403
+ # already enrolled — must refuse rather than have the branch below
404
+ # decide whether anything needs confirming, which would silently
405
+ # accept an outcome nobody confirmed.
406
+ if confirmed_org_id is not None and enrollment.org_id != confirmed_org_id:
407
+ raise PolicyServiceError("enrollment_confirmation_stale")
382
408
  _verify_state_storage(self._paths, trusted_uid=self._trusted_uid, create=True)
383
409
  with _policy_lock(
384
410
  self._paths.lock,
@@ -386,6 +412,20 @@ class PolicyService:
386
412
  trusted_uid=self._trusted_uid,
387
413
  ) as acquired:
388
414
  assert acquired
415
+ # `config` and `identity` were read before this lock was even
416
+ # requested, across a network call (`client.get_me()`) that can
417
+ # pause for a long time. A second, concurrent `configure()` can
418
+ # have rotated the developer's credentials and had its own
419
+ # `install()` call win this same lock first, in which case this
420
+ # call's stale capture must not now overwrite that fresher
421
+ # enrollment — re-verify the credentials this attempt targeted
422
+ # are still the ones on disk before touching anything.
423
+ current_config = _load_enrollment_config(config_path)
424
+ if (
425
+ current_config.api_url.rstrip("/") != config.api_url.rstrip("/")
426
+ or current_config.token != config.token
427
+ ):
428
+ raise PolicyServiceError("enrollment_attempt_stale")
389
429
  enrollment_exists = _trusted_state_file_exists(
390
430
  self._paths.enrollment,
391
431
  mode=0o600,
@@ -399,19 +439,19 @@ class PolicyService:
399
439
  if state_exists and not enrollment_exists:
400
440
  raise PolicyServiceError("state_lost")
401
441
  bootstrap_needed = not enrollment_exists
442
+ replacing_org = False
402
443
  if enrollment_exists:
403
444
  existing_enrollment = Enrollment.from_path(self._paths.enrollment)
404
445
  if not state_exists and existing_enrollment.bootstrap_complete:
405
446
  raise PolicyServiceError("state_lost")
406
- same_deployment = (
447
+ same_endpoint = (
407
448
  existing_enrollment.api_url == enrollment.api_url
408
- and existing_enrollment.org_id == enrollment.org_id
409
449
  and existing_enrollment.user == enrollment.user
410
450
  and existing_enrollment.uid == enrollment.uid
411
451
  and existing_enrollment.gid == enrollment.gid
412
452
  and existing_enrollment.home == enrollment.home
413
453
  )
414
- if not same_deployment:
454
+ if not same_endpoint:
415
455
  raise PolicyServiceError("re_enrollment_unsupported")
416
456
  if state_exists:
417
457
  state = PolicyState.from_path(self._paths.state)
@@ -419,6 +459,14 @@ class PolicyService:
419
459
  raise PolicyServiceError("re_enrollment_unsupported")
420
460
  else:
421
461
  state = PolicyState.initial(account.user)
462
+ if existing_enrollment.org_id != enrollment.org_id:
463
+ if not replace_enrollment:
464
+ raise EnrollmentReplacementRequired(
465
+ new_org_name=identity.org.name,
466
+ new_org_id=identity.org.id,
467
+ )
468
+ replacing_org = True
469
+ state = PolicyState.initial(account.user)
422
470
  if not existing_enrollment.bootstrap_complete:
423
471
  if (
424
472
  state != PolicyState.initial(account.user)
@@ -447,7 +495,21 @@ class PolicyService:
447
495
  raise PolicyServiceError("state_lost")
448
496
  state = PolicyState.initial(account.user)
449
497
  try:
450
- if bootstrap_needed:
498
+ if replacing_org:
499
+ try:
500
+ self._manager.uninstall(self._paths)
501
+ except (OSError, subprocess.SubprocessError) as exc:
502
+ raise PolicyServiceError("service_uninstall_failed") from exc
503
+ _remove_artifact(self._paths, trusted_uid=self._trusted_uid)
504
+ # The timer is stopped and the previous artifact is gone before
505
+ # the org-specific revision anchor changes. Write the reset
506
+ # anchor first so a crash cannot leave a new incomplete
507
+ # enrollment beside the previous org's revision state.
508
+ _atomic_write_json(self._paths.state, state.to_json(), 0o644)
509
+ incomplete = replace(enrollment, bootstrap_complete=False)
510
+ _atomic_write_json(self._paths.enrollment, incomplete.to_json(), 0o600)
511
+ _atomic_write_json(self._paths.enrollment, enrollment.to_json(), 0o600)
512
+ elif bootstrap_needed:
451
513
  if not enrollment_exists:
452
514
  incomplete = replace(enrollment, bootstrap_complete=False)
453
515
  _atomic_write_json(self._paths.enrollment, incomplete.to_json(), 0o600)
@@ -464,6 +526,51 @@ class PolicyService:
464
526
  raise PolicyServiceError("service_install_failed") from exc
465
527
  return self.apply()
466
528
 
529
+ def runtime_version(self) -> str | None:
530
+ try:
531
+ _verify_runtime(self._paths.runtime, trusted_uid=self._trusted_uid)
532
+ _verify_runtime(
533
+ self._paths.runtime.with_name("python"),
534
+ trusted_uid=self._trusted_uid,
535
+ )
536
+ result = subprocess.run(
537
+ [str(self._paths.runtime), "--version"],
538
+ check=True,
539
+ capture_output=True,
540
+ text=True,
541
+ timeout=5,
542
+ )
543
+ except (OSError, subprocess.SubprocessError, PolicyServiceError):
544
+ return None
545
+ parts = result.stdout.strip().split()
546
+ if len(parts) < 2 or parts[0] != "stacktrace":
547
+ return None
548
+ return parts[1]
549
+
550
+ def install_via_sudo(self, *, user: str, expected_api_url: str) -> None:
551
+ _verify_runtime(self._paths.runtime, trusted_uid=self._trusted_uid)
552
+ _verify_runtime(
553
+ self._paths.runtime.with_name("python"),
554
+ trusted_uid=self._trusted_uid,
555
+ )
556
+ try:
557
+ subprocess.run(
558
+ [
559
+ "/usr/bin/sudo",
560
+ str(self._paths.runtime),
561
+ "remote",
562
+ "policy",
563
+ "install-service",
564
+ "--user",
565
+ user,
566
+ "--api-url",
567
+ expected_api_url,
568
+ ],
569
+ check=True,
570
+ )
571
+ except (OSError, subprocess.SubprocessError) as exc:
572
+ raise PolicyServiceError("policy_service_update_failed") from exc
573
+
467
574
  def uninstall(self) -> None:
468
575
  self._require_root()
469
576
  _verify_state_storage(self._paths, trusted_uid=self._trusted_uid, create=True)
@@ -1014,6 +1121,24 @@ def _install_artifact(paths: ServicePaths, content: bytes, *, trusted_uid: int)
1014
1121
  os.close(directory_fd)
1015
1122
 
1016
1123
 
1124
+ def _remove_artifact(paths: ServicePaths, *, trusted_uid: int) -> None:
1125
+ try:
1126
+ paths.managed_directory.lstat()
1127
+ except FileNotFoundError:
1128
+ return
1129
+ except OSError as exc:
1130
+ raise PolicyServiceError("install_directory_untrusted") from exc
1131
+ directory_fd = _open_trusted_directory(paths, trusted_uid=trusted_uid)
1132
+ try:
1133
+ try:
1134
+ os.unlink(ARTIFACT_NAME, dir_fd=directory_fd)
1135
+ except FileNotFoundError:
1136
+ return
1137
+ os.fsync(directory_fd)
1138
+ finally:
1139
+ os.close(directory_fd)
1140
+
1141
+
1017
1142
  def _open_trusted_directory(
1018
1143
  paths: ServicePaths,
1019
1144
  *,
@@ -21,6 +21,7 @@ import fcntl
21
21
  import os
22
22
  from collections.abc import Callable, Iterator
23
23
  from contextlib import contextmanager
24
+ from dataclasses import replace
24
25
  from pathlib import Path
25
26
  from typing import Any
26
27
 
@@ -373,7 +374,12 @@ def _advance_asset_id(*, config: RemoteConfig, config_path: Path, asset_id: str)
373
374
  f"re-addressed {moved} pending upload(s) to the current asset",
374
375
  err=True,
375
376
  )
376
- new_config = RemoteConfig(api_url=config.api_url, token=config.token, asset_id=asset_id)
377
+ # Built from `current`, not `config`: every other field must survive this
378
+ # repoint, including one neither read above nor yet imagined, and `current`
379
+ # is the freshest on-disk value for all of them (`config`'s copy of fields
380
+ # other than api_url/token/asset_id can already be stale by the time this
381
+ # runs, the same way its credentials can).
382
+ new_config = replace(current, asset_id=asset_id)
377
383
  try:
378
384
  save_remote_config(new_config, config_path)
379
385
  except OSError as exc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stacktrace-cli
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: CLI for Stacktrace — Detection and Response platform for AI Agents.
5
5
  Project-URL: Homepage, https://stacktrace.ai
6
6
  Author-email: "Stacktrace AI, Inc" <founders@stacktrace.ai>
@@ -51,7 +51,7 @@ Requires Python 3.11 or newer.
51
51
 
52
52
  ```console
53
53
  $ stacktrace --version
54
- stacktrace 0.7.0 (openaca 0.8.0)
54
+ stacktrace 0.7.1 (openaca 0.8.0)
55
55
  ```
56
56
 
57
57
  ## Quick start
@@ -1,4 +1,4 @@
1
- stacktrace_cli/__init__.py,sha256=Kc2LCYeh6DI6-UmF0Nj5ISIRydY2SiHYtjb_mpuZvOE,390
1
+ stacktrace_cli/__init__.py,sha256=V6ZJM6RZm-R-GR2nyKgnVM2f6rOQpWclQ5yFgmAWILA,390
2
2
  stacktrace_cli/__main__.py,sha256=F_tqj3PRzeBtY-uvBs923H8oc-s9bebhIh6UnAUwNYc,448
3
3
  stacktrace_cli/analysis.py,sha256=NZ9ENz65IP1pNztZH_A7zZGz6ywFUCNbJ1NOMrf1094,26969
4
4
  stacktrace_cli/build.py,sha256=lXMXBMoQtXC5qVMAWBsJavcVfXPmJiAZgV3cRBqUvyk,4846
@@ -19,7 +19,7 @@ stacktrace_cli/correlate/render.py,sha256=hJZhZcqE4bKAeXIegddY7uViCyp7NAPTQl1cj9
19
19
  stacktrace_cli/daemon/__init__.py,sha256=YP0eKpNcRl1GzKO1GXh9gOoigViU2duV-PEOw0lLLwQ,59
20
20
  stacktrace_cli/daemon/activity_log.py,sha256=0DRxWBpcDElsbJp6DXYURjxQ1jnJD3snQVyWqvGnnqc,3243
21
21
  stacktrace_cli/daemon/bom.py,sha256=Y0DIFTs_srqcgSSUrd5x0eySfMdiN6f0UKBb9SwZ0-M,2231
22
- stacktrace_cli/daemon/cli.py,sha256=TY5-PG_ROWuuFjqASo0RP-lO9dSHXBJrPlnMQUMA6LA,25664
22
+ stacktrace_cli/daemon/cli.py,sha256=SN55aOpuyo4id3kYw-ZqL2jnuTfhcLAYkXgJNVpq9rw,25653
23
23
  stacktrace_cli/daemon/client.py,sha256=Enn2I5eZb0AMBkOAh2z1HCqFTPR4U1jP6l4oDdl8ttY,13928
24
24
  stacktrace_cli/daemon/composition.py,sha256=XCwTitMb7co1UMgE_xYQQIeSlPpLQIDaPkt9dlkEiTU,9867
25
25
  stacktrace_cli/daemon/config.py,sha256=drY0BGjLSj0ZoGnIedX8i7s7twBr7lA7Z2BjZyMgVEE,12090
@@ -64,12 +64,15 @@ stacktrace_cli/monitor/__init__.py,sha256=akhxzlSGyvbG-3WyC-5Ch5nuYP1E6xga8rzkJ6
64
64
  stacktrace_cli/monitor/inventory.py,sha256=chL3wfpmpAqSooXZfMX5-MqIj4m5bbGSzznjnsfz2e8,19640
65
65
  stacktrace_cli/monitor/reasoning.py,sha256=wYLxyqsuHf4xfaAygbsA07gUKAozsg6jlL5usHQEzSM,15128
66
66
  stacktrace_cli/monitor/render.py,sha256=0YdiBt-TyF_3LmjtiIoliAqntxluxPn_quNa3DfXhUE,34422
67
- stacktrace_cli/monitor/server.py,sha256=PuQWTktdGZ5ulTubBR8T_Fknzb-JnNwzP8b3DZ7tja8,32435
67
+ stacktrace_cli/monitor/server.py,sha256=XNYV6vZ0FoGcX4qwD64cf-8KM1cDQdN7Sq7GDr5sOzQ,32714
68
68
  stacktrace_cli/monitor/state.py,sha256=CdzCU1MmMxLmKbccfVrfggxwuaNi10hfEsHH42EgZdA,10757
69
69
  stacktrace_cli/monitor/verdicts.py,sha256=-0IEiaQ1h4DYHuAcgaI14CMNqm5Buqw2J8BmL6-IVJQ,2277
70
70
  stacktrace_cli/monitor/watch.py,sha256=1obMQBrcoB-7zCf5JxIEi1PaQn5m-tA0-y8QsYpTzuI,38942
71
71
  stacktrace_cli/monitor/site/app.js,sha256=n_zTbQq4xj84uVN9V12fazPENzyiy50hYHrVv_0yG-Y,132729
72
- stacktrace_cli/monitor/site/index.html,sha256=hkk7keNearyLIoB5CSvLLF2FzIAb8w4DC3YPzfl4C_A,6686
72
+ stacktrace_cli/monitor/site/apple-icon.png,sha256=TF3ch0XTFq53OYnnhs95RWLZunR1uImwXk7wOO8r-9s,10077
73
+ stacktrace_cli/monitor/site/favicon.ico,sha256=wA-ZZy9P4qAvo2P6lQclOyqnBoRNLJ9fs3VPA6R19A0,8089
74
+ stacktrace_cli/monitor/site/icon.svg,sha256=EqgzQS97ZOKFJQIf2HQlhhyW-hJ6oE0f6A2u74igDCk,564
75
+ stacktrace_cli/monitor/site/index.html,sha256=RNeya9nGL49-Eqs6bqbK30aAlGdVWNSaU82T11ki5NM,6842
73
76
  stacktrace_cli/monitor/site/inventory.js,sha256=hLLGgSebEdti-m6cGzUrsstGZw_U_s2yA0YzXC70M8Q,24056
74
77
  stacktrace_cli/monitor/site/styles.css,sha256=3Y806hzVgrekm2pf_f_Ptb47fBQkIhKcEcDPuYo-07A,49135
75
78
  stacktrace_cli/monitor/site/fonts/OFL.txt,sha256=3eGN4fg4dKG6QBHOpLJobIeUAs3eQnmZBiykH5b-TGY,9975
@@ -77,18 +80,18 @@ stacktrace_cli/monitor/site/fonts/dm-mono-400-latin.woff2,sha256=_XUh81MaXM_GVbJ
77
80
  stacktrace_cli/monitor/site/fonts/dm-mono-500-latin.woff2,sha256=DiY9tSeXCG52NnnFT4Te2MwSSYebwn3KK9XdRG9tnzY,8724
78
81
  stacktrace_cli/monitor/site/fonts/dm-sans-latin.woff2,sha256=qlMHFrDTUYZq99v6Pu5BIPs28tBxuv-MI0GFFBhlx_8,62556
79
82
  stacktrace_cli/remote/__init__.py,sha256=Ba5yZEovOFNDWfgj0ycJkvglUTLN3C47RUQyX-r4opI,82
80
- stacktrace_cli/remote/cli.py,sha256=gG-rNClKkrh9wKWLb6cNBcO0Gy8qbjazqBF_vAZZ9kc,21904
81
- stacktrace_cli/remote/client.py,sha256=8iIETRe_RL2J8FbEzC3BSEO1gwsztlz3_vvQVy7SHbE,15454
82
- stacktrace_cli/remote/config.py,sha256=xBTLYfY2OX_JieeIYe3ZCrQqzk2-xKZ8MJfJDXDRlxY,4256
83
+ stacktrace_cli/remote/cli.py,sha256=0sMlAjuAqczi_OvUVoc0ATFqWDg1DC6TG1fkgMxaNdI,24413
84
+ stacktrace_cli/remote/client.py,sha256=bE51_nU0Vy_Iz9J3MWfml_E4JbxidmEYSC8B9ef5kN8,12323
85
+ stacktrace_cli/remote/config.py,sha256=item5B6aqlON58AXSfWrFNiVNVe-ojBi1Fyx_K0F3Rg,4287
83
86
  stacktrace_cli/remote/detect_payload.py,sha256=EvdUsVj_5jH4C3f5Zg21D3YRUaUSowjcr_5uVeQNRG0,21643
84
87
  stacktrace_cli/remote/identity.py,sha256=CosDLK_k-SzpEveG09NKYz5yzkw1aUq4T4saT5QLIzc,13438
85
88
  stacktrace_cli/remote/payload.py,sha256=2lDlwHpA7xT7lfFXY3e-lHNag6g6RTCo-vmgUWEXKWg,17645
86
89
  stacktrace_cli/remote/policy.py,sha256=0pDZcPavCTXvul1Zs6oi7-J3K0YoXPNFa3j1aOJG3Zk,3202
87
- stacktrace_cli/remote/policy_service.py,sha256=SARzxeGZ3glKjN8hxkvZjZFzVvwMFiTcTwG-1bwQEyw,54033
90
+ stacktrace_cli/remote/policy_service.py,sha256=RcCOfbo0HyPkfHd-GjCbsZyFWT728YRE-3ej3rYKvkM,59781
88
91
  stacktrace_cli/remote/policy_worker.py,sha256=SGW6TesL5O7D5JO95ZOCmWP9TiQffD5JphBVUHcOG3M,2042
89
92
  stacktrace_cli/remote/redact.py,sha256=0J3t2x_a7U-Yt3_aWcGrK_s72LsYSdLUo_ITCRJ_KKU,67631
90
93
  stacktrace_cli/remote/spool.py,sha256=uTKUGF5b48RUJyUiTsky_on6tnsi5NHjMljsNSN2LuI,20871
91
- stacktrace_cli/remote/sync.py,sha256=WW9GK1O_uHmdEBvj48b3X5SP6BQkCcrd1ZRd2qRbKyU,38468
94
+ stacktrace_cli/remote/sync.py,sha256=WA9mRamkglAMltWGf7szfVWbRNQ3fqyVwr8Yc0mvV6Y,38822
92
95
  stacktrace_cli/remote/sync_detect.py,sha256=JI0U6RBsmIDZJM5gwgu9EfVYfSU95y_OnOWY1F3X8og,23043
93
96
  stacktrace_cli/remote/upload_contract.py,sha256=dITNGtjAkCLzCWczSzm99dnWsXmilM-4wEk22qcYiOc,45879
94
97
  stacktrace_cli/sessions/__init__.py,sha256=mWb3Z42sN_UqxFQCPpXpsa7Nqe8J9Vd3jspItXqtTmI,76
@@ -105,7 +108,7 @@ stacktrace_cli/telemetry/state.py,sha256=-7UmK8bBFj3Zh3bzX0kJwxn7-CvreMar_0x1NiZ
105
108
  stacktrace_cli/webhook/__init__.py,sha256=6knjTNQA1ErM-AO6fQ60_TZqZzbVjQeDMU1XIPPCpo0,35
106
109
  stacktrace_cli/webhook/cli.py,sha256=g-QlGkiW9bXqgxa11KDjhJmhcaU4mxZwdUS2-o2LFyQ,7491
107
110
  stacktrace_cli/webhook/config.py,sha256=Uv6_5xtKlO5RQG3FkhtSS8TFKYlc5CAZ0Kwd0CCq99c,8655
108
- stacktrace_cli-0.7.0.dist-info/METADATA,sha256=cLoG_IqM05oC1Z0HpRF3lBGunJG8yfx2dUzRwfP-zGI,9160
109
- stacktrace_cli-0.7.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
110
- stacktrace_cli-0.7.0.dist-info/entry_points.txt,sha256=HXQBr0nneh_o4A5t-4kZzmhjbh358P0G4M59kWdnejc,111
111
- stacktrace_cli-0.7.0.dist-info/RECORD,,
111
+ stacktrace_cli-0.7.1.dist-info/METADATA,sha256=1NqRSL7HM02qgkMxfFgqLFEqQvBR9PLwshb4YZC9wK8,9160
112
+ stacktrace_cli-0.7.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
113
+ stacktrace_cli-0.7.1.dist-info/entry_points.txt,sha256=HXQBr0nneh_o4A5t-4kZzmhjbh358P0G4M59kWdnejc,111
114
+ stacktrace_cli-0.7.1.dist-info/RECORD,,