stacktrace-cli 0.6.1__py3-none-any.whl → 0.7.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.6.1"
5
+ __version__ = "0.7.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
@@ -483,8 +483,9 @@ def _slack_payload(payload: dict[str, object]) -> dict[str, object]:
483
483
  if isinstance(kinds, list) and kinds:
484
484
  joined = ", ".join(_mrkdwn(kind) for kind in kinds)
485
485
  fields.append({"type": "mrkdwn", "text": f"*Evidence*\n{joined}"})
486
+ # Severity only (ADR-0088). Confidence is nearly always `high`, so in a
487
+ # channel it is a field that says nothing; the JSON format still carries it.
486
488
  fields.append({"type": "mrkdwn", "text": f"*Severity*\n{_mrkdwn(severity)}"})
487
- fields.append({"type": "mrkdwn", "text": f"*Confidence*\n{_mrkdwn(payload['confidence'])}"})
488
489
 
489
490
  blocks: list[dict[str, object]] = [
490
491
  {"type": "section", "text": {"type": "mrkdwn", "text": f"*{title}*"}},
@@ -38,11 +38,13 @@ import os
38
38
  import re
39
39
  import shlex
40
40
  from collections.abc import Sequence
41
+ from urllib.parse import urlsplit
41
42
 
42
43
  from stacktrace_cli.correlate.composition import Advisory, Component, Composition
43
44
  from stacktrace_cli.correlate.join import Resolution
44
45
  from stacktrace_cli.correlate.observed import (
45
46
  _MAX_SHELL_DEPTH,
47
+ _segment_capabilities,
46
48
  capabilities,
47
49
  command_segments,
48
50
  command_segments_with_ops,
@@ -90,6 +92,7 @@ def _credential_egress(call: ToolCallLike, ref: SessionRef) -> Detection | None:
90
92
  if not matches:
91
93
  return None
92
94
  kinds = sorted({m.kind for m in matches})
95
+ target = _outside_target(call)
93
96
  return Detection(
94
97
  rule_id="stacktrace-credential-egress",
95
98
  severity="high",
@@ -109,10 +112,13 @@ def _credential_egress(call: ToolCallLike, ref: SessionRef) -> Detection | None:
109
112
  # arguments object, and a key is chosen by whoever wrote the
110
113
  # call: an MCP server's schema, or a literal like
111
114
  # `{"/private/customer/path": "sk-..."}` in a body the agent
112
- # was handed. Offset is the descriptor ADR-0006 names, locates
113
- # the match just as well for the responder who has the session,
114
- # and cannot carry anything the other side wrote.
115
- detail=f"at offset {match.offset} in the arguments of {tool_name_for(call)}",
115
+ # was handed. The offset is withheld too: a reader of an alert
116
+ # cannot use a character position, and the span already
117
+ # locates the call. Where it went is what they act on
118
+ # (ADR-0086).
119
+ detail=f"sent to {target}"
120
+ if target
121
+ else f"in the arguments of {tool_name_for(call)}",
116
122
  )
117
123
  for match in matches
118
124
  ),
@@ -131,6 +137,122 @@ def _command(call: ToolCallLike) -> str | None:
131
137
  return command if isinstance(command, str) and command else None
132
138
 
133
139
 
140
+ #: Vendor CLIs that talk to one service, and the name a reader knows it by.
141
+ #: A CLI that reaches whatever it is pointed at -- `curl`, `git`, `npm` -- is
142
+ #: absent, because its name says nothing about who answered; the URL it was
143
+ #: given does (`_shell_target`).
144
+ _SERVICE_CLIS: dict[str, str] = {
145
+ "gh": "GitHub",
146
+ "glab": "GitLab",
147
+ "aws": "AWS",
148
+ "gcloud": "Google Cloud",
149
+ "az": "Azure",
150
+ "kubectl": "Kubernetes",
151
+ "vercel": "Vercel",
152
+ "netlify": "Netlify",
153
+ "railway": "Railway",
154
+ "flyctl": "Fly",
155
+ "heroku": "Heroku",
156
+ "wrangler": "Cloudflare",
157
+ "stripe": "Stripe",
158
+ "supabase": "Supabase",
159
+ "firebase": "Firebase",
160
+ "doctl": "DigitalOcean",
161
+ }
162
+
163
+
164
+ #: An MCP server or tool name that may be named: identifier characters only,
165
+ #: so never a sentence (no spaces) and never a path (no slashes) (ADR-0086).
166
+ _NAMEABLE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}")
167
+
168
+ #: A host as `urlsplit` returns it: lowercase letters, digits, dots, hyphens.
169
+ _HOSTNAME = re.compile(r"[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?")
170
+
171
+
172
+ def _host(value: object) -> str | None:
173
+ """The host of an `http(s)://` URL, and nothing else of it.
174
+
175
+ Scheme, userinfo, port, path and query all stay behind: they are where
176
+ tokens and customer identifiers sit, and the host is what answered.
177
+ """
178
+ if not isinstance(value, str) or not value.lower().startswith(("http://", "https://")):
179
+ return None
180
+ try:
181
+ parts = urlsplit(value)
182
+ host = parts.hostname
183
+ except ValueError:
184
+ return None
185
+ if not host or not _HOSTNAME.fullmatch(host):
186
+ return None
187
+ # `hostname` is lowercased, which hides a case-sensitive credential shape
188
+ # (`AKIA…`) from `find_secrets`, so the host is scanned as it was written.
189
+ if find_secrets(parts.netloc.rpartition("@")[2]):
190
+ return None
191
+ return host
192
+
193
+
194
+ def _shell_target(command: str) -> str | None:
195
+ """The one service or host a shell command reached, or None.
196
+
197
+ A vendor CLI names its service (`GitHub (gh)`). Any other segment that
198
+ reaches the network must name exactly one URL host. A command that reached
199
+ two places (`gh … && curl …`), or a network segment with no URL (`git
200
+ push origin`), names nothing: the record does not say which one answered,
201
+ and naming the wrong one sends the reader to the wrong service.
202
+
203
+ Reads `command_segments`, the one shell parser, and asks it only which
204
+ programs ran, whether each reached the network, and its URL arguments.
205
+ """
206
+ targets: set[str] = set()
207
+ for program, arguments in command_segments(command):
208
+ if program in _SERVICE_CLIS:
209
+ targets.add(f"{_SERVICE_CLIS[program]} ({program})")
210
+ continue
211
+ if not any(
212
+ name == "network_egress" for name, _ in _segment_capabilities(program, arguments)
213
+ ):
214
+ continue
215
+ hosts = {host for argument in arguments if (host := _host(argument))}
216
+ if len(hosts) != 1:
217
+ return None
218
+ targets |= hosts
219
+ return targets.pop() if len(targets) == 1 else None
220
+
221
+
222
+ def _outside_target(call: ToolCallLike) -> str | None:
223
+ """Who answered this call, where the record names one (ADR-0086).
224
+
225
+ `linear MCP · search_issues` for an MCP call, the URL's host for WebFetch,
226
+ and `_shell_target` for a shell command. The server and tool names are the
227
+ session's and the server's own words, so they are named only when shaped
228
+ like identifiers, and nothing secret-shaped is named at all.
229
+
230
+ The one place a finding names the tool an MCP server chose, and only for
231
+ the citations ADR-0086 lists. Everywhere else `tool_name_for` holds.
232
+ """
233
+ # A denied call reached nothing, so nothing answered it, whatever its
234
+ # error text says.
235
+ if denied(call):
236
+ return None
237
+ target: str | None
238
+ if call.mcp_server:
239
+ if not _NAMEABLE.fullmatch(call.mcp_server):
240
+ return None
241
+ # `customer-support-mcp` already says what it is.
242
+ server = call.mcp_server
243
+ target = server if server.lower().endswith("mcp") else f"{server} MCP"
244
+ if _NAMEABLE.fullmatch(call.tool_name):
245
+ target += f" · {call.tool_name}"
246
+ elif call.tool_name == "WebFetch":
247
+ target = _host((call.arguments or {}).get("url"))
248
+ else:
249
+ command = _command(call)
250
+ target = _shell_target(command) if command else None
251
+ if target is None or find_secrets(target):
252
+ return None
253
+ return target
254
+
255
+
134
256
  def _block_body(call: ToolCallLike) -> str | None:
135
257
  """The body `_agent_blocked` classifies a block from.
136
258
 
@@ -298,9 +420,18 @@ def _blocked_remediation(
298
420
 
299
421
  def _block_span(session: SessionLike, turn: TurnLike) -> str:
300
422
  """A turn-level span: the same session and turn key a call span is built
301
- from, without a call index, because there is no call. It cannot collide
302
- with one -- a call span always carries the index."""
303
- return f"{session.session_id}:{turn.key}"
423
+ from, with index 0.
424
+
425
+ The index is there because the span grammar requires one: the upload
426
+ contract and Fleet both refuse a span that does not end in `:<n>`, so a
427
+ block reported in a reply never reached Fleet. It cannot collide with a
428
+ call span, because this is only ever built for a turn with no calls.
429
+
430
+ Accepted: a reply-only block that rang under the retired
431
+ `stacktrace-agent-blocked` rule (before 0.6.0) is matched by that rule's
432
+ old anchor, which had no index, so it can ring once more on a machine
433
+ that upgrades from before 0.6.0 straight to this version."""
434
+ return f"{session.session_id}:{turn.key}:0"
304
435
 
305
436
 
306
437
  def block_spans_by_turn(session: SessionLike) -> dict[str, TurnLike]:
@@ -439,12 +570,16 @@ def _agent_blocked(session: SessionLike, ref: SessionRef) -> tuple[Detection, ..
439
570
  if not denied(call):
440
571
  worked_at.append((turn.position, index))
441
572
  continue
573
+ # Who refused, where a service did. A spend limit or a safety
574
+ # check is the client refusing, and naming the host the call was
575
+ # aimed at would blame it instead.
576
+ target = _outside_target(call) if reason.attribution == "upstream" else None
442
577
  found.setdefault(reason.code, []).append(
443
578
  Evidence(
444
579
  span=call.span,
445
580
  kind=reason.code,
446
581
  detail=_blocked_detail(
447
- tool_name_for(call),
582
+ target or tool_name_for(call),
448
583
  reason.specific(_block_body(call)),
449
584
  ),
450
585
  )
@@ -1581,7 +1716,7 @@ def _credential_persisted(session: SessionLike, ref: SessionRef) -> tuple[Detect
1581
1716
  Evidence(
1582
1717
  span=call.span,
1583
1718
  kind=match.kind,
1584
- detail=f"{match.kind} at offset {match.offset}",
1719
+ detail=f"written to a file by {tool_name_for(call)}",
1585
1720
  )
1586
1721
  for match in matches
1587
1722
  ),
@@ -1773,6 +1908,8 @@ def _tool_retry_storm(session: SessionLike, ref: SessionRef) -> tuple[Detection,
1773
1908
  last_key = key
1774
1909
 
1775
1910
  if counters.get(key, 0) >= _RETRY_THRESHOLD:
1911
+ # What the agent kept calling, not the tool it called it with.
1912
+ target = _outside_target(call) or tool_name_for(call)
1776
1913
  det = Detection(
1777
1914
  rule_id="stacktrace-tool-retry-storm",
1778
1915
  severity="low",
@@ -1783,7 +1920,7 @@ def _tool_retry_storm(session: SessionLike, ref: SessionRef) -> tuple[Detection,
1783
1920
  Evidence(
1784
1921
  span=span,
1785
1922
  kind="identical_retry",
1786
- detail=f"{tool_name_for(call)} called with identical parameters",
1923
+ detail=f"{target} called with identical parameters",
1787
1924
  )
1788
1925
  for span in storm_spans
1789
1926
  ),
@@ -97,6 +97,9 @@ def tool_name_for(call: ToolCallLike) -> str:
97
97
  the ADR puts it — `Detection.components` where it was placed, a
98
98
  `Participant.name` where it was not.
99
99
 
100
+ ADR-0086 is the one exception: `deterministic._outside_target` names the
101
+ server and tool in the citations of the rules it lists, and nowhere else.
102
+
100
103
  Lives here rather than beside one rule because both stages need it, and a
101
104
  second copy of this argument is a second chance to get it wrong — which is
102
105
  the class `test_untrusted_content_never_travels.py` exists to close.
@@ -700,16 +700,22 @@ function eventsFrom(sessions) {
700
700
 
701
701
  /* Components *invoked*, by kind. The demo's strip counted what was installed;
702
702
  * this snapshot carries only what was reached, so the chip says "invoked"
703
- * rather than borrowing a word for a number it does not hold. */
703
+ * rather than borrowing a word for a number it does not hold.
704
+ *
705
+ * Every kind counted here is one `renderComposition` draws a chip for, so the
706
+ * chips add up to the total. A built-in sub-agent has no component type and
707
+ * the category `subagent`, which no chip named; anything else unnamed is
708
+ * "Other" rather than a number the total holds and no chip shows. */
704
709
  function compositionOf(sessions) {
705
710
  const byType = {};
706
711
  const seen = new Set();
707
712
  for (const session of sessions) {
708
713
  for (const row of session.activity || []) {
709
714
  if (unplaceable(row)) continue;
710
- const type = row.component_type || row.category;
715
+ const raw = row.component_type || row.category;
716
+ const type = raw === "subagent" ? "agent" : COMPONENT_KINDS[raw] ? raw : "other";
711
717
  const key = type + "\n" + (row.component_identity || row.component_name || row.tool_name);
712
- if (!type || seen.has(key)) continue;
718
+ if (!raw || seen.has(key)) continue;
713
719
  seen.add(key);
714
720
  byType[type] = (byType[type] || 0) + 1;
715
721
  }
@@ -779,7 +785,7 @@ let lastPolicies = [];
779
785
  let lastComplete = false;
780
786
  let lastSessionCount = 0;
781
787
 
782
- /* Whether the reader opened or closed the list with "What we check", or
788
+ /* Whether the reader opened or closed the list with "Scanned Policies", or
783
789
  * `null` while they have not and the list follows the view: open while a
784
790
  * range loads or a pass checks, and when a finished pass found nothing;
785
791
  * folded only once a finished pass has findings, so the cards lead.
@@ -838,8 +844,8 @@ const POLICY_WORD = { checking: "checking", clear: "clear", idle: "" };
838
844
  * the pass has finished -- the false silence ADR-0004 rules out.
839
845
  *
840
846
  * Finished, a clean window keeps the list, every policy ticked clear, and a
841
- * window with findings folds it to its one line so the cards lead. "What we
842
- * check" opens or folds the list for the view on screen (`policyListOpen`). */
847
+ * window with findings folds it to its one line so the cards lead. "Scanned
848
+ * Policies" opens or folds the list for the view on screen (`policyListOpen`). */
843
849
  function renderPolicyCheck(view) {
844
850
  lastPolicyView = view;
845
851
  const node = el("policy-check");
@@ -892,7 +898,7 @@ function renderPolicyCheck(view) {
892
898
  const open = policyListOpen === null ? !findings : policyListOpen;
893
899
  lastPolicyOpen = open;
894
900
  const toggle = '<button type="button" class="pc-toggle" aria-expanded="' + open + '">' +
895
- "What we check " + (open ? "▴" : "▾") + "</button>";
901
+ "Scanned Policies " + (open ? "▴" : "▾") + "</button>";
896
902
  const found = {};
897
903
  for (const alert of alerts) found[alert.rule_id] = (found[alert.rule_id] || 0) + 1;
898
904
  const rows = lastPolicies.map(function (policy) {
@@ -910,7 +916,7 @@ function renderPolicyCheck(view) {
910
916
  (open ? '<ul class="pc-list">' + rows + "</ul>" : ""));
911
917
  }
912
918
 
913
- /* "What we check": open the list if it is folded, fold it if it is open. */
919
+ /* "Scanned Policies": open the list if it is folded, fold it if it is open. */
914
920
  function togglePolicyList() {
915
921
  policyListOpen = !lastPolicyOpen;
916
922
  renderPolicyCheck(lastPolicyView);
@@ -86,12 +86,6 @@
86
86
  </div>
87
87
  </section>
88
88
 
89
- <div class="panel-head panel-head-logs">
90
- <span class="panel-kick">Live</span>
91
- <h2>What your agents are doing</h2>
92
- <span class="panel-note">read from this machine's own transcripts</span>
93
- </div>
94
-
95
89
  <div class="logs">
96
90
  <section class="logcol">
97
91
  <header class="logcol-head">
@@ -39,9 +39,10 @@ function typeLabel(type, many) {
39
39
  return titled(type);
40
40
  }
41
41
 
42
- /* The scanner's labels, in tile order. A label outside these gets a tile of
43
- * its own rather than being folded into one: the page never re-grades. */
44
- const INV_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"];
42
+ /* The scanner's labels, in tile order. A label outside these -- UNKNOWN among
43
+ * them -- gets a tile of its own only when a vulnerability carries it, rather
44
+ * than being folded into one: the page never re-grades. */
45
+ const INV_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
45
46
 
46
47
  /* Full class names rather than built by concatenation, so the check that every
47
48
  * class the page emits is styled can see each one. */
@@ -72,7 +73,7 @@ function checkCell(row) {
72
73
  case "not_checked":
73
74
  return { text: "Not checked", cls: "inv-check inv-check-warn" };
74
75
  case "no_package":
75
- return { text: "No package", cls: "inv-check inv-check-dim" };
76
+ return { text: "None", cls: "inv-check inv-check-dim" };
76
77
  default:
77
78
  return { text: "Checking…", cls: "inv-check inv-check-dim" };
78
79
  }
@@ -95,6 +96,10 @@ const invOpen = new Set();
95
96
 
96
97
  /* --- the URL -------------------------------------------------------------- */
97
98
 
99
+ /* The Vulnerabilities tab's views. A bookmarked one no longer listed reads
100
+ * as the default rather than as a view with no chip pressed. */
101
+ const INV_VIEWS = ["vulnerabilities", "posture"];
102
+
98
103
  /* Which filters each tab writes to the hash. */
99
104
  const INV_HASH_KEYS = {
100
105
  inventory: ["agent", "type", "show", "inactive"],
@@ -109,6 +114,7 @@ function readHash(hash) {
109
114
  const filter = Object.assign({}, INV_DEFAULTS);
110
115
  for (const key of INV_HASH_KEYS[tab] || []) {
111
116
  if (!params.has(key)) continue;
117
+ if (key === "view" && !INV_VIEWS.includes(params.get(key))) continue;
112
118
  filter[key] = key === "inactive" ? params.get(key) === "1" : params.get(key);
113
119
  }
114
120
  return { tab: INV_TABS.includes(tab) ? tab : "live", filter: filter };
@@ -188,14 +194,17 @@ const instances = (issues) =>
188
194
  issues.reduce((sum, issue) => sum + issue.affected.length + issue.subjects.length, 0);
189
195
 
190
196
  /* The Vulnerabilities tab's tiles. Distinct vulnerabilities by label, then
191
- * posture issues and observations as instances: two trusted directories are
192
- * two posture issues on one row. */
197
+ * posture issues as instances: two trusted directories are two posture issues
198
+ * on one row. */
193
199
  function severityTiles(doc, filter) {
194
200
  const scope = Object.assign({}, filter, { severity: "" });
195
201
  const counts = new Map(INV_SEVERITIES.map((s) => [s, 0]));
196
202
  for (const v of vulnerabilityRows(doc, scope)) {
197
203
  counts.set(label(v.severity), (counts.get(label(v.severity)) || 0) + 1);
198
204
  }
205
+ /* The selected label keeps its tile, at 0 if nothing in scope carries it,
206
+ * so a filter that is on always has a tile to show it and to clear it. */
207
+ if (filter.severity && !counts.has(filter.severity)) counts.set(filter.severity, 0);
199
208
  const checked = doc.checked === "done";
200
209
  const tiles = Array.from(counts.entries()).map(([severity, count]) => ({
201
210
  key: "severity", value: severity, label: titled(severity.toLowerCase()),
@@ -205,10 +214,6 @@ function severityTiles(doc, filter) {
205
214
  key: "view", value: "posture", label: "Posture issues",
206
215
  count: instances(issueRows(doc.posture, doc, scope)),
207
216
  });
208
- tiles.push({
209
- key: "view", value: "observations", label: "Observations",
210
- count: instances(issueRows(doc.observations, doc, scope)),
211
- });
212
217
  return tiles;
213
218
  }
214
219
 
@@ -405,7 +410,7 @@ function renderVulnerabilitiesTab(doc) {
405
410
  html += '<p class="inv-summary">' + esc(vulnerabilitiesLine(doc, invFilter)) + "</p>";
406
411
  html += '<div class="inv-filters"><span class="inv-group">' +
407
412
  chip("view", "vulnerabilities", "Vulnerabilities") + chip("view", "posture", "Posture issues") +
408
- chip("view", "observations", "Observations") + "</span>" + agentSelect("vul-agent", doc) +
413
+ "</span>" + agentSelect("vul-agent", doc) +
409
414
  "</div>";
410
415
 
411
416
  if (invFilter.view === "vulnerabilities") {
@@ -428,9 +433,8 @@ function renderVulnerabilitiesTab(doc) {
428
433
  return html + table(["Vulnerability", "Severity", "Fixed in", "Affects"], body);
429
434
  }
430
435
 
431
- const posture = invFilter.view === "posture";
432
- const issues = issueRows(posture ? doc.posture : doc.observations, doc, invFilter);
433
- if (!issues.length) return html + emptyBody(posture ? "No posture issues." : "No observations.");
436
+ const issues = issueRows(doc.posture, doc, invFilter);
437
+ if (!issues.length) return html + emptyBody("No posture issues.");
434
438
  const body = issues.map((issue) =>
435
439
  '<tr class="inv-row"><td>' + esc(issue.summary) +
436
440
  (issue.sent_to_fleet ? "" : ' <span class="inv-marker">Not sent to Fleet</span>') +
@@ -439,7 +443,7 @@ function renderVulnerabilitiesTab(doc) {
439
443
  "</td><td>" + esc(fmt(issue.affected.length + issue.subjects.length)) + "</td></tr>" +
440
444
  '<tr class="inv-under"><td colspan="4">' + componentLines(issue.affected) +
441
445
  subjectLines(issue.subjects) + "</td></tr>").join("");
442
- return html + table([posture ? "Posture issue" : "Observation", "Severity", "Fix", "Affects"], body);
446
+ return html + table(["Posture issue", "Severity", "Fix", "Affects"], body);
443
447
  }
444
448
 
445
449
  /* The one note these tabs draw above themselves: that they describe this
@@ -299,7 +299,7 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
299
299
  ran; an alert is a sentence, a citation list and a remediation, and it was
300
300
  the one being wrapped. Order in the markup is trace-then-alerts, so the
301
301
  ratio reads 2:3. */
302
- .logs { display:grid; grid-template-columns:minmax(0,2fr) minmax(0,3fr); gap:1.2rem; align-items:start; }
302
+ .logs { display:grid; grid-template-columns:minmax(0,2fr) minmax(0,3fr); gap:1.2rem; align-items:start; margin-top:1.6rem; }
303
303
  @media (max-width:860px){ .logs { grid-template-columns:1fr; } }
304
304
 
305
305
  /* Each feed is a panel with a real edge. Unbordered columns read as loose text
@@ -539,10 +539,8 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
539
539
  /* ---------- overview section: two trend tables ---------- */
540
540
  .overview { margin:0 0 0.4rem; }
541
541
  /* ---------- section headers ----------
542
- One treatment for both readings, so Overview and the live feeds read as two
543
- halves of the same page rather than two widgets. The mono eyebrow and its
544
- coral dot are stacktrace-site's `.eyebrow`; the rule under the title is what
545
- gives the section its weight against the run bar above it. */
542
+ The rule under the title is what gives the section its weight against the
543
+ run bar above it. */
546
544
  .panel-head {
547
545
  display:flex; align-items:baseline; gap:0.7rem; flex-wrap:wrap;
548
546
  margin:0 0 0.9rem; padding-bottom:0.5rem;
@@ -551,13 +549,6 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
551
549
  .panel-head h2 {
552
550
  margin:0; font-size:1.35rem; font-weight:700; letter-spacing:-0.03em; color:var(--ink);
553
551
  }
554
- .panel-kick {
555
- display:inline-flex; align-items:center; gap:0.45em;
556
- font-family:var(--mono); font-size:0.62rem; font-weight:500;
557
- letter-spacing:0.14em; text-transform:uppercase; color:var(--ink-dim);
558
- }
559
- .panel-kick::before { content:""; width:7px; height:7px; border-radius:50%; background:var(--coral); }
560
- .panel-note { margin-left:auto; font-family:var(--mono); font-size:0.68rem; color:var(--ink-faint); }
561
552
  /* The window reads at nearly the heading's weight, because every number under
562
553
  it is "in this window" and a range mistaken for the default is a page
563
554
  misread rather than a detail missed. */
@@ -578,7 +569,6 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
578
569
  .range-btn[aria-pressed="true"] {
579
570
  color:var(--paper); background:var(--ink); border-color:var(--ink);
580
571
  }
581
- .panel-head-logs { margin-top:2rem; }
582
572
 
583
573
  .ov-win { font-family:var(--mono); font-size:0.66rem; font-weight:400; color:var(--ink-faint); font-style:italic; }
584
574
  .ov-grid { display:grid; grid-template-columns:1fr 1fr; gap:1.1rem; }
@@ -37,6 +37,11 @@ from stacktrace_cli.remote.config import (
37
37
  save_remote_config,
38
38
  )
39
39
  from stacktrace_cli.remote.policy import compile_remote_policy
40
+ from stacktrace_cli.remote.policy_service import (
41
+ ApplyResult,
42
+ PolicyServiceError,
43
+ default_policy_service,
44
+ )
40
45
  from stacktrace_cli.remote.spool import clear_pending_uploads
41
46
  from stacktrace_cli.remote.sync import (
42
47
  NOT_CONFIGURED,
@@ -195,6 +200,88 @@ def policy_compile(
195
200
  )
196
201
 
197
202
 
203
+ @policy.command("apply")
204
+ def policy_apply() -> None:
205
+ """Run one privileged Fleet policy reconciliation."""
206
+ try:
207
+ result = default_policy_service().apply()
208
+ except PolicyServiceError as exc:
209
+ raise click.ClickException(exc.code) from exc
210
+ click.echo(_policy_apply_message(result))
211
+
212
+
213
+ @policy.command("install-service")
214
+ @click.option("--user", required=True, help="Developer account whose Claude configuration is read.")
215
+ @click.option("--api-url", required=True, help="Administrator-confirmed Fleet API origin.")
216
+ def policy_install_service(user: str, api_url: str) -> None:
217
+ """Enroll and install automatic privileged policy reconciliation."""
218
+ try:
219
+ result = default_policy_service().install(
220
+ user=user,
221
+ expected_api_url=api_url,
222
+ )
223
+ except PolicyServiceError as exc:
224
+ raise click.ClickException(exc.code) from exc
225
+ if result.action == "no_policy":
226
+ click.echo("Installed policy service; Fleet has no published policy")
227
+ elif result.action == "coalesced":
228
+ click.echo("Installed policy service; policy reconciliation already running")
229
+ else:
230
+ click.echo(f"Installed service and policy revision {result.revision}")
231
+
232
+
233
+ @policy.command("uninstall-service")
234
+ def policy_uninstall_service() -> None:
235
+ """Remove automatic reconciliation while retaining the installed policy."""
236
+ try:
237
+ default_policy_service().uninstall()
238
+ except PolicyServiceError as exc:
239
+ raise click.ClickException(exc.code) from exc
240
+ click.echo("Uninstalled policy service; managed policy and enrollment were retained")
241
+
242
+
243
+ @policy.command("status")
244
+ def policy_status() -> None:
245
+ """Show content-free local policy deployment status."""
246
+ try:
247
+ status = default_policy_service().status()
248
+ except PolicyServiceError as exc:
249
+ raise click.ClickException(exc.code) from exc
250
+ click.echo(f"Service: {'installed' if status.service_installed else 'not installed'}")
251
+ click.echo(f"User: {status.user}")
252
+ last_seen_revision = (
253
+ str(status.last_seen_revision) if status.last_seen_revision is not None else "none"
254
+ )
255
+ installed_revision = (
256
+ str(status.installed_revision) if status.installed_revision is not None else "none"
257
+ )
258
+ click.echo(f"Last seen revision: {last_seen_revision}")
259
+ click.echo(f"Installed revision: {installed_revision}")
260
+ click.echo(f"Artifact: {'verified' if status.artifact_verified else 'not verified'}")
261
+ last_checked = status.last_checked_at.isoformat() if status.last_checked_at else "never"
262
+ click.echo(f"Last checked: {last_checked}")
263
+ last_compile = (
264
+ status.last_successful_compile_at.isoformat()
265
+ if status.last_successful_compile_at
266
+ else "never"
267
+ )
268
+ click.echo(f"Last successful compile: {last_compile}")
269
+ if status.last_error_code is not None:
270
+ last_error_at = status.last_error_at.isoformat() if status.last_error_at else "unknown"
271
+ click.echo(f"Last error: {status.last_error_code} at {last_error_at}")
272
+ click.echo("Effective: not verified; use Claude /status or claude doctor")
273
+
274
+
275
+ def _policy_apply_message(result: ApplyResult) -> str:
276
+ if result.action == "coalesced":
277
+ return "Policy reconciliation already running"
278
+ if result.action == "no_policy":
279
+ return "Fleet has no published policy; existing managed policy was retained"
280
+ if result.revision is None:
281
+ return f"Policy reconciliation {result.action}"
282
+ return f"{result.action.capitalize()} policy revision {result.revision}"
283
+
284
+
198
285
  @sync.command()
199
286
  @click.option(
200
287
  "--kind",
@@ -136,6 +136,12 @@ class MeResult:
136
136
  token: TokenResult
137
137
 
138
138
 
139
+ @dataclass(frozen=True)
140
+ class PolicyEnvelope:
141
+ document: JsonObject | None
142
+ revision: int
143
+
144
+
139
145
  @dataclass(frozen=True)
140
146
  class AgentStatusResult:
141
147
  """One agent kind composed on an asset.
@@ -245,12 +251,23 @@ class RemoteClient:
245
251
  ),
246
252
  )
247
253
 
248
- def get_policy_document(self) -> JsonObject | None:
254
+ def get_policy_envelope(self) -> PolicyEnvelope:
249
255
  data = self._request("GET", "/api/v1/policy")
250
- document = data.get("document")
256
+ if "document" not in data:
257
+ raise RemoteClientError("remote backend response has invalid document field")
258
+ document = data["document"]
251
259
  if document is not None and not isinstance(document, dict):
252
260
  raise RemoteClientError("remote backend response has invalid document field")
253
- return document
261
+ revision = data.get("revision")
262
+ if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
263
+ raise RemoteClientError("remote backend response has invalid revision field")
264
+ if (document is None) != (revision == 0):
265
+ raise RemoteClientError("remote backend response has invalid document field")
266
+ return PolicyEnvelope(document=document, revision=revision)
267
+
268
+ def get_policy_document(self) -> JsonObject | None:
269
+ """Compatibility surface for the manual one-shot compiler."""
270
+ return self.get_policy_envelope().document
254
271
 
255
272
  def get_asset(self, asset_id: str) -> AssetStatusResult:
256
273
  data = self._request("GET", f"/api/v1/assets/{asset_id}")
@@ -341,7 +358,7 @@ def _required_object(data: JsonObject, key: str) -> JsonObject:
341
358
 
342
359
  def _required_str(data: JsonObject, key: str) -> str:
343
360
  value = data.get(key)
344
- if not isinstance(value, str):
361
+ if not isinstance(value, str) or not value:
345
362
  raise RemoteClientError(f"remote backend response missing string field: {key}")
346
363
  return value
347
364