stacktrace-cli 0.6.1__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.6.1"
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:
@@ -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.
@@ -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",
@@ -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);
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>
@@ -86,12 +89,6 @@
86
89
  </div>
87
90
  </section>
88
91
 
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
92
  <div class="logs">
96
93
  <section class="logcol">
97
94
  <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; }