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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/daemon/cli.py +13 -9
- stacktrace_cli/monitor/server.py +7 -0
- stacktrace_cli/monitor/site/apple-icon.png +0 -0
- stacktrace_cli/monitor/site/favicon.ico +0 -0
- stacktrace_cli/monitor/site/icon.svg +7 -0
- stacktrace_cli/monitor/site/index.html +3 -0
- stacktrace_cli/remote/cli.py +97 -22
- stacktrace_cli/remote/client.py +0 -83
- stacktrace_cli/remote/config.py +5 -1
- stacktrace_cli/remote/policy_service.py +134 -9
- stacktrace_cli/remote/sync.py +7 -1
- {stacktrace_cli-0.7.0.dist-info → stacktrace_cli-0.7.1.dist-info}/METADATA +2 -2
- {stacktrace_cli-0.7.0.dist-info → stacktrace_cli-0.7.1.dist-info}/RECORD +16 -13
- {stacktrace_cli-0.7.0.dist-info → stacktrace_cli-0.7.1.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.7.0.dist-info → stacktrace_cli-0.7.1.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/daemon/cli.py
CHANGED
|
@@ -210,14 +210,13 @@ def _reject_unobservable_kinds(config: DaemonConfig) -> None:
|
|
|
210
210
|
|
|
211
211
|
|
|
212
212
|
@daemon.command()
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
stacktrace_cli/monitor/server.py
CHANGED
|
@@ -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
|
|
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>
|
stacktrace_cli/remote/cli.py
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 =
|
|
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_
|
|
435
|
-
return "ot_
|
|
509
|
+
return f"ot_••••{token[-4:]}"
|
|
510
|
+
return "ot_••••"
|
|
436
511
|
return "***"
|
|
437
512
|
|
|
438
513
|
|
stacktrace_cli/remote/client.py
CHANGED
|
@@ -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:
|
stacktrace_cli/remote/config.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
*,
|
stacktrace_cli/remote/sync.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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/
|
|
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=
|
|
81
|
-
stacktrace_cli/remote/client.py,sha256=
|
|
82
|
-
stacktrace_cli/remote/config.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
109
|
-
stacktrace_cli-0.7.
|
|
110
|
-
stacktrace_cli-0.7.
|
|
111
|
-
stacktrace_cli-0.7.
|
|
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,,
|
|
File without changes
|
|
File without changes
|