stacktrace-cli 0.2.1__py3-none-any.whl → 0.2.3__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.
@@ -1,3 +1,3 @@
1
1
  """The `stacktrace` command-line interface — detection and response for AI agents."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.3"
stacktrace_cli/cli.py CHANGED
@@ -224,15 +224,17 @@ def sessions(
224
224
  )
225
225
  @click.option(
226
226
  "--escalate/--no-escalate",
227
- default=True,
227
+ default=False,
228
228
  show_default=True,
229
- help="Send flagged sessions to the agent's own CLI for semantic analysis. On "
230
- "by default, capped by --budget. It is the only stage that sends anything "
231
- "session-derived off this machine, and it spends the developer's own "
232
- "provider quota, so --no-escalate turns it off and the cap keeps a noisy "
233
- "day bounded. It does not make the run offline: correlation runs first "
234
- "either way and matches advisories by sending the package coordinates of "
235
- "the components a session invoked to osv.dev.",
229
+ help="Send flagged sessions to the agent's own CLI for semantic analysis. Off "
230
+ "by default: it is the only stage that sends anything session-derived off "
231
+ "this machine, and it spends the developer's own provider quota, so a run "
232
+ "that does neither is the one to reach for first. --escalate turns it on "
233
+ "and --budget caps a noisy day. Leaving it off does not make the run "
234
+ "offline: correlation runs first either way and matches advisories by "
235
+ "sending the package coordinates of the components a session invoked to "
236
+ "osv.dev. Sessions that qualified are still counted, and named in "
237
+ "--format json, so the run says what it did not do.",
236
238
  )
237
239
  @click.option(
238
240
  "--budget",
@@ -295,18 +297,20 @@ def detect(
295
297
  families carry their own severity ladders, because a stalled loop and a
296
298
  leaked credential cannot share one (ADR-0010).
297
299
 
298
- Two stages need no model or credential. The third sends flagged sessions to
299
- the agent's own CLI: on by default and capped by --budget, with
300
- --no-escalate to turn it off.
300
+ Two stages need no model or credential, and they are the two that run by
301
+ default. The third sends flagged sessions to the agent's own CLI:
302
+ --escalate turns it on, capped by --budget. Sessions that qualified for it
303
+ are counted either way, and named in --format json, so a run without it
304
+ says so rather than reading as a clean one.
301
305
 
302
306
  That third stage is the one that sends session content off this machine:
303
307
  the agent's own CLI hands the prompts, arguments and results a rule needs
304
308
  to the provider it is already authenticated against (ADR-0004 -- provider
305
309
  affinity, so a transcript goes back to the vendor that produced it, and
306
- there is no fallback to any other). --no-escalate leaves the two stages
307
- that send nothing.
310
+ there is no fallback to any other). Without --escalate the run is the two
311
+ stages that send nothing.
308
312
 
309
- One network call happens before any of them regardless of --no-escalate:
313
+ One network call happens before any of them, with or without --escalate:
310
314
  correlation matches advisories by sending the package coordinates of the
311
315
  components a session invoked to osv.dev. Coordinates only -- never a
312
316
  prompt, an argument or a result.
@@ -324,9 +328,13 @@ def detect(
324
328
  escalate=escalate,
325
329
  budget=budget,
326
330
  sample_budget=sample_budget,
327
- # Only ever consulted when stage 3 runs: there is nothing to reuse
328
- # otherwise, and opening a directory to discover that is waste.
329
- cache=VerdictCache(default_directory()) if cache and escalate else None,
331
+ # Not gated on `escalate`. A verdict already paid for is an
332
+ # answer this run has, and `escalate` governs whether new ones may
333
+ # be commissioned, not whether old ones may be read -- the rule
334
+ # `run_detector` states at its cache pass. Gating it here made a
335
+ # session read as graded or ungraded depending on a flag that
336
+ # spent nothing either way.
337
+ cache=VerdictCache(default_directory()) if cache else None,
330
338
  )
331
339
  except ValueError as error:
332
340
  raise click.ClickException(str(error)) from error
@@ -16,13 +16,23 @@ answer naming a component that was never involved.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import base64
20
+ import hashlib
19
21
  import json
22
+ import os
20
23
  import subprocess
24
+ import sys
21
25
  import tempfile
22
26
  from collections.abc import Callable, Sequence
23
27
  from collections.abc import Set as AbstractSet
24
28
  from dataclasses import dataclass
25
29
  from datetime import UTC, datetime
30
+ from importlib.metadata import (
31
+ Distribution,
32
+ PackageNotFoundError,
33
+ PackagePath,
34
+ distribution,
35
+ )
26
36
  from pathlib import Path
27
37
  from typing import Any
28
38
 
@@ -36,6 +46,140 @@ from stacktrace_cli.correlate.composition import (
36
46
 
37
47
  _AGENT_KIND_PROPERTY = "openaca:agent_kind"
38
48
 
49
+ #: The name OpenACA publishes its console script under. `.exe` on Windows
50
+ #: because that is the launcher installers write there, and this package claims
51
+ #: `Operating System :: OS Independent`.
52
+ _SCRIPT = "openaca.exe" if os.name == "nt" else "openaca"
53
+
54
+
55
+ def _openaca() -> str:
56
+ """The OpenACA console script this package pins, addressed by path.
57
+
58
+ The bare string `"openaca"` is a `PATH` lookup, and `PATH` is not where the
59
+ pinned copy lives: `uv tool install stacktrace-cli` links only the
60
+ `stacktrace` entry point out of the tool environment, so the OpenACA that
61
+ `[project.dependencies]` resolved sits in `bin/` reachable but unnamed. A
62
+ bare name therefore selects whichever global, `pipx` or `--user` copy the
63
+ machine carries — a stale one silently does the work and its version need
64
+ not be the one this package depends on — or, on a machine with none,
65
+ nothing at all. This is the rule `docs/specs/cli-composition.md` states for
66
+ the mounted commands, applied to the one path that reaches OpenACA as a
67
+ subprocess.
68
+
69
+ **The path is the one the pinned distribution's own install recorded, not a
70
+ guess from this interpreter's location.** Python defines separate prefix,
71
+ virtual-environment, user and home installation schemes, and only the first
72
+ two put console scripts beside the interpreter: `pip install --user
73
+ stacktrace-cli` puts them under `site.USER_BASE` while `sys.executable`
74
+ stays the base interpreter. Deriving the path from `sys.executable` alone
75
+ therefore names a file that does not exist under a supported install, and
76
+ `detect` dies at process launch before it can build anything.
77
+ `importlib.metadata` answers instead: it resolves the OpenACA on *this*
78
+ interpreter's `sys.path` — the pinned one, by construction — and that
79
+ install's `RECORD` names the script file it wrote, wherever its scheme put
80
+ it.
81
+
82
+ **The only thing run is a file that install recorded writing, still holding
83
+ the contents it recorded.** Nothing is run for being named `openaca` in a
84
+ directory the distribution merely shares: an installation scheme pairs a
85
+ library directory with a scripts directory, but that scripts directory holds
86
+ the launchers of *every* distribution installed into the scheme and records
87
+ nothing about which one wrote any of them, so co-location establishes where
88
+ installers put scripts in general and never that this distribution put this
89
+ one there. Nor does a recorded path settle it on its own, because a record
90
+ describes what an install wrote rather than reserving where it wrote it: the
91
+ scripts directory stays shared afterwards, so a later install of another
92
+ distribution can overwrite that very file while the record still names it.
93
+ A stale, global, independently managed or overwritten launcher of that name
94
+ reintroduces exactly the version skew this addressing exists to remove, and
95
+ it does so invisibly — a wrong-version OpenACA can return a structurally
96
+ valid BOM. Where ownership cannot be established the answer is a
97
+ `RuntimeError` naming why, which `_build` renders as a legible CLI error.
98
+
99
+ Still the published console script, so the ADR-0007 seam is unchanged: this
100
+ module imports no OpenACA internals and `tests/test_seam_boundary.py` holds
101
+ that line.
102
+ """
103
+ try:
104
+ installed = distribution("openaca")
105
+ except PackageNotFoundError as error:
106
+ raise RuntimeError(
107
+ "the OpenACA this package pins is not installed on "
108
+ f"{sys.executable}'s import path, so no {_SCRIPT!r} on this machine "
109
+ "can be known to be it; reinstalling stacktrace-cli restores it"
110
+ ) from error
111
+ recorded = _recorded_script(installed)
112
+ if recorded is not None:
113
+ return str(recorded)
114
+ raise RuntimeError(
115
+ f"the pinned OpenACA installed at {installed.locate_file('')} records no "
116
+ f"{_SCRIPT!r} console script of its own that still holds the contents it "
117
+ "recorded; a copy found by name alone — by sharing a directory with that "
118
+ "install, or by having replaced a file it wrote — need not be the version "
119
+ "this package depends on, so none is run; reinstalling stacktrace-cli "
120
+ "restores it"
121
+ )
122
+
123
+
124
+ def _recorded_script(installed: Distribution) -> Path | None:
125
+ """The console script the pinned OpenACA's install recorded, if it did.
126
+
127
+ A `RECORD` entry says the distribution `[project.dependencies]` resolved
128
+ wrote a file of these contents at this path — which is more than a directory
129
+ a scheme suggested can say, and less than the path alone would suggest,
130
+ because the two halves age differently: the path stays shared, the contents
131
+ do not. Scripts live outside `site-packages` and are recorded relative to
132
+ it, so the entry resolves correctly under every scheme.
133
+
134
+ Absent when the install enumerated no files: `importlib.metadata` answers
135
+ `None` when the metadata that lists them is missing — `RECORD` for a
136
+ `dist-info` install — and an `egg-info` install lists the sources it was
137
+ built from rather than the scripts it wrote. That is a distribution that
138
+ cannot name its own launcher, not licence to run someone else's: the only
139
+ other thing available about such an install is where it sits, and a
140
+ directory is shared by everything installed alongside it.
141
+
142
+ Absent, too, when the file at the recorded path is not the file that was
143
+ recorded there. Every candidate is authenticated against its own recorded
144
+ digest, because the two halves of an entry answer different questions: the
145
+ path says where the install wrote a launcher, and the digest says what it
146
+ wrote. Only the second survives another install overwriting the shared
147
+ scripts directory afterwards.
148
+ """
149
+ for recorded in installed.files or ():
150
+ if recorded.name == _SCRIPT:
151
+ candidate = Path(str(installed.locate_file(recorded))).resolve()
152
+ if _holds_recorded_contents(recorded, candidate):
153
+ return candidate
154
+ return None
155
+
156
+
157
+ def _holds_recorded_contents(recorded: PackagePath, candidate: Path) -> bool:
158
+ """Whether the file now at a recorded path hashes to what was recorded for it.
159
+
160
+ Fails closed on every reason the question cannot be answered — no digest,
161
+ a digest naming an algorithm this interpreter does not implement, an
162
+ unreadable file — because "we could not check" and "it matches" differ by
163
+ exactly the guarantee this locator exists to give, and the unchecked case
164
+ fails invisibly: a wrong-version OpenACA returns a structurally valid BOM.
165
+ The digest field is optional in a `RECORD` row, so an install can decline
166
+ to say what it wrote; that install cannot then be shown to own the file
167
+ sitting at the path it names, and a reinstall is what restores the
168
+ evidence.
169
+
170
+ Digests are base64url without padding, so the padding is stripped from
171
+ both sides rather than assumed absent from either.
172
+ """
173
+ digest = recorded.hash
174
+ if digest is None:
175
+ return False
176
+ try:
177
+ computed = hashlib.new(digest.mode, candidate.read_bytes()).digest()
178
+ except (ValueError, TypeError, OSError):
179
+ return False
180
+ encoded = base64.urlsafe_b64encode(computed).decode("ascii")
181
+ return encoded.rstrip("=") == digest.value.rstrip("=")
182
+
39
183
 
40
184
  def load_bom(path: Path) -> tuple[str, Built]:
41
185
  """Read a supplied Agent BOM, or say why it cannot be used.
@@ -95,7 +239,7 @@ def build_bom(
95
239
  with tempfile.TemporaryDirectory() as scratch:
96
240
  directory = Path(scratch)
97
241
  argv = [
98
- "openaca",
242
+ _openaca(),
99
243
  "bom",
100
244
  "endpoint",
101
245
  "--kind",
@@ -291,8 +435,16 @@ def _scan(
291
435
  array, and a family we do not read is a fact about the scan rather than a
292
436
  fault in it.
293
437
  """
438
+ try:
439
+ openaca = _openaca()
440
+ except RuntimeError:
441
+ # The same answer as an unreadable response, for the same reason: with no
442
+ # OpenACA that can be known to be the pinned one, this scan did not run.
443
+ # A `--bom` run reaches here without having built anything, so this is
444
+ # the first place that can be discovered — and `{}` would say we looked.
445
+ return None
294
446
  result = subprocess.run(
295
- ["openaca", "scan", "bom", "--input", str(bom_path), "--format", "json"],
447
+ [openaca, "scan", "bom", "--input", str(bom_path), "--format", "json"],
296
448
  capture_output=True,
297
449
  text=True,
298
450
  check=False,
@@ -220,14 +220,11 @@ def render_text(result: DetectorRun, detail: bool = False) -> str:
220
220
 
221
221
  lines.extend(_rollup_lines(result))
222
222
 
223
- if result.unknowns:
224
- lines.append("Could not settle:")
225
- counts: dict[str, int] = {}
226
- for unknown in result.unknowns:
227
- counts[unknown.reason] = counts.get(unknown.reason, 0) + 1
228
- for reason, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
229
- lines.append(f" {count:>4} {reason}")
230
- lines.append("")
223
+ # The per-reason breakdown of what went unsettled is no longer printed by
224
+ # default. The count still travels in the summary line, `--json` still
225
+ # carries every `Unknown` whole, and the reasoning stage still names its
226
+ # own outcomes below, so nothing is dropped from the record -- only from
227
+ # the default terminal view.
231
228
 
232
229
  lines.extend(_reasoning_lines(result))
233
230
 
@@ -256,7 +253,7 @@ def render_text(result: DetectorRun, detail: bool = False) -> str:
256
253
  #: session-level row, whether or not it also has unanswered questions.
257
254
  _REASONING_OUTCOMES: dict[str, str] = {
258
255
  "budget_exhausted": "deferred: the run's --budget was reached",
259
- "reasoning_not_requested": "not requested: --no-escalate",
256
+ "reasoning_not_requested": "not requested: re-run with --escalate to analyse them",
260
257
  "analyzer_unavailable": "no analyzer available for the agent kind",
261
258
  "session_too_large": "too large for the analyzer's context window",
262
259
  "insufficient_context": "compaction removed the turns a rule needed",
@@ -270,9 +267,9 @@ _REASONING_OUTCOMES: dict[str, str] = {
270
267
  def _reasoning_lines(result: DetectorRun) -> list[str]:
271
268
  """What the third stage actually spent, and where every escalation went.
272
269
 
273
- Stated as accounting rather than as one number, because with escalation on
274
- by default (ADR-0004, amended) the question a reader has is no longer *did
275
- it run* but *what did this run cost me*. Three outcomes are not the same
270
+ Stated as accounting rather than as one number. With the stage opt-in the
271
+ reader has both questions -- *did it run* and, when it did, *what did this
272
+ run cost me* -- and one number answers neither. Three outcomes are not the same
276
273
  fact: a model call spends seconds and money, a cache hit settles the same
277
274
  question for free, and a deferred escalation spends nothing and leaves the
278
275
  concern open.
@@ -176,6 +176,12 @@ def _banners(analysis: Analysis) -> list[str]:
176
176
  A blank column and a clean run render identically, which is the one
177
177
  confusion this page refuses: a reader who cannot tell "nothing happened"
178
178
  from "nothing could be read" has been told nothing.
179
+
180
+ Both banners are about a *reader* that came back with nothing. A kind with
181
+ no composition is not one of those and no longer appears: the calls it
182
+ accounts for are read, and the page shows what it can place them to.
183
+ `view.without_composition` is still reported by `stacktrace correlate`,
184
+ where the record rather than the console is the subject.
179
185
  """
180
186
  banners: list[str] = []
181
187
  for failure in analysis.run.collection_failures:
@@ -183,8 +189,6 @@ def _banners(analysis: Analysis) -> list[str]:
183
189
  anonymous = len(analysis.view.kind_anonymous)
184
190
  if anonymous:
185
191
  banners.append(f"{anonymous} session(s) skipped: no agent kind to attribute them to")
186
- for kind in analysis.view.without_composition:
187
- banners.append(f"{kind}: no composition, so every call reads as unresolved")
188
192
  return banners
189
193
 
190
194
 
@@ -145,16 +145,20 @@ function renderBanners(banners) {
145
145
 
146
146
  function renderCounts(state) {
147
147
  const summary = state.summary || {};
148
- /* Coverage is a fraction, never a bare percentage: 1 of 1 and 400 of 400 are
149
- * both "100%" and say very different things about how much this page knows.
150
- * And it is `null` until correlation has run, which is the first second or
151
- * so — said as "placing…" rather than shown as 0/0, which would read as
152
- * "nothing placed" instead of "not counted yet". */
153
- const coverage = summary.coverage;
148
+ /* Sessions and actions, and no coverage fraction. `6,867/7,055 placed` is
149
+ * the count of what this page leaves off, stated: the gap between the two
150
+ * numbers is exactly the unplaceable rows, so the header reinstated by
151
+ * arithmetic what the rows below had stopped saying. The snapshot still
152
+ * carries it — `stacktrace correlate` is where coverage is the subject.
153
+ *
154
+ * Actions are counted off the rows this page keeps rather than read from
155
+ * `summary.actions`, which is the server's total over every call in the
156
+ * window. Read straight through, the header claimed more actions than the
157
+ * feed below it could account for. */
158
+ const actions = eventsFrom(state.sessions || []).length;
154
159
  text(el("live-counts"),
155
160
  " — " + plural(summary.sessions || 0, "session") +
156
- " · " + plural(summary.actions || 0, "action") +
157
- " · " + (coverage ? coverage.resolved + "/" + coverage.total + " placed" : "placing…"));
161
+ " · " + plural(actions, "action"));
158
162
  }
159
163
 
160
164
  /* --- the demo's vocabulary ----------------------------------------------- */
@@ -209,7 +213,6 @@ const RULE_PLAIN = {
209
213
  "stacktrace-credential-egress": "A password or token was sent to an outside service.",
210
214
  "stacktrace-capability-crossing": "Private data was read, then sent outside.",
211
215
  "stacktrace-intent-drift": "It acted on something nobody asked about.",
212
- "stacktrace-unsanctioned-mcp-tool-use": "A tool ran that isn't in your installed set.",
213
216
  "stacktrace-guardrail-modification": "A session changed the agent's own rules or settings.",
214
217
  "stacktrace-injection-marker": "Injected-instruction markers were found in what the agent read.",
215
218
  "stacktrace-destructive-action": "A destructive action was taken (deleting or overwriting).",
@@ -227,20 +230,32 @@ const KIND_TAG = {
227
230
  subagent: ["sub-agent", "kt-sub"],
228
231
  mcp: ["mcp", "kt-conn"],
229
232
  command: ["command", "kt-cmd"],
230
- unresolved: ["⚠ unresolved", "kt-warn"],
231
233
  };
232
234
 
233
235
  /* Plural, because every one of these labels a count. */
234
236
  const KIND_LABEL = {
235
237
  mcp: "MCP servers", skill: "Skills", command: "Commands",
236
- subagent: "Sub-agents", tool: "Built-in tools", unresolved: "Unresolved",
238
+ subagent: "Sub-agents", tool: "Built-in tools",
237
239
  };
238
240
 
239
241
  /* Most consequential first, rather than by count: sorted by count the
240
242
  * built-ins win every window and push the MCP servers — the only category
241
243
  * describing a call that left the machine — under a number three orders of
242
244
  * magnitude larger. */
243
- const KIND_ORDER = ["mcp", "skill", "command", "subagent", "tool", "unresolved"];
245
+ const KIND_ORDER = ["mcp", "skill", "command", "subagent", "tool"];
246
+
247
+ /* The category the correlator gives a call it could not place. Every surface
248
+ * on this page drops these rows: an unplaceable call names no component, so
249
+ * the row could only say that something happened and nothing here claims it,
250
+ * which is not a thing a reader can act on. The count is still computed
251
+ * server-side and still reaches the detector — this is a decision about the
252
+ * page, not about the record. */
253
+ const UNPLACEABLE = "unresolved";
254
+
255
+ /* Whether the snapshot row is one of those. */
256
+ function unplaceable(row) {
257
+ return row.category === UNPLACEABLE;
258
+ }
244
259
  const TOP_PER_KIND = 3;
245
260
 
246
261
  const SEVERITY_WORD = { critical: "Serious", high: "High", medium: "Medium", low: "Low" };
@@ -294,10 +309,13 @@ function evParts(e) {
294
309
  provenance: "a link to an outside service",
295
310
  };
296
311
  }
312
+ /* A category this build has never heard of. Named as itself rather than
313
+ * described: anything else this function said about it would be invented,
314
+ * and the one category it used to describe no longer reaches the page. */
297
315
  return {
298
316
  name: e.name,
299
- action: "used a tool nothing here claims",
300
- provenance: "no component on this machine declares it",
317
+ action: "used " + (e.tool || "a tool"),
318
+ provenance: e.kind ? "category " + e.kind : "",
301
319
  };
302
320
  }
303
321
 
@@ -321,6 +339,7 @@ function eventsFrom(sessions) {
321
339
  const activity = session.activity || [];
322
340
  for (let index = 0; index < activity.length; index += 1) {
323
341
  const row = activity[index];
342
+ if (unplaceable(row)) continue;
324
343
  events.push({
325
344
  id: row.span,
326
345
  kind: row.category,
@@ -336,11 +355,6 @@ function eventsFrom(sessions) {
336
355
  * the skills a reader had just run sat under MCP calls from half a day
337
356
  * earlier. Named for what it is, because it is not the start. */
338
357
  at: session.last_active || session.started_at,
339
- /* Both worth a reader's attention; only the first is a claim about
340
- * attribution. Correlation keeps a refused call's resolution, so the
341
- * row beside the label names the component it would have reached. */
342
- unplaced: row.category === "unresolved",
343
- refused: row.status === "denied",
344
358
  order: index,
345
359
  });
346
360
  }
@@ -356,7 +370,8 @@ function compositionOf(sessions) {
356
370
  const seen = new Set();
357
371
  for (const session of sessions) {
358
372
  for (const row of session.activity || []) {
359
- const type = row.component_type || (row.category === "unresolved" ? null : row.category);
373
+ if (unplaceable(row)) continue;
374
+ const type = row.component_type || row.category;
360
375
  const key = type + "\n" + (row.component_identity || row.component_name || row.tool_name);
361
376
  if (!type || seen.has(key)) continue;
362
377
  seen.add(key);
@@ -372,6 +387,7 @@ function activitySummaryOf(sessions) {
372
387
  const named = {};
373
388
  for (const session of sessions) {
374
389
  for (const row of session.activity || []) {
390
+ if (unplaceable(row)) continue;
375
391
  byKind[row.category] = (byKind[row.category] || 0) + 1;
376
392
  if (!named[row.category]) named[row.category] = new Map();
377
393
  const label = entryLabel(row);
@@ -531,9 +547,9 @@ let revealTimer = null;
531
547
  * Routine built-ins (Bash, Read, Edit) are the constant background of every
532
548
  * session: 5,795 of one window's 7,065 calls. They collapse **per project**
533
549
  * into one counting row for the agent. Notable events — skills, MCP servers,
534
- * sub-agents, commands, and anything unresolved — are rare and important, so
535
- * each gets its own row, keyed by span and never coalesced: two runs of
536
- * `superpowers:brainstorming` are two things that happened.
550
+ * sub-agents and commands — are rare and important, so each gets its own row,
551
+ * keyed by span and never coalesced: two runs of `superpowers:brainstorming`
552
+ * are two things that happened.
537
553
  *
538
554
  * Grouping by tool name instead gave `Bash ×38 / Read ×16 / Bash ×3 / Edit
539
555
  * ×20` per session, with every skill and connector buried under it. */
@@ -609,14 +625,6 @@ function countAgain(entry, e) {
609
625
  entry.timeEl.textContent = fmtRange(entry.first, entry.last);
610
626
  }
611
627
  }
612
- /* A refusal is worth seeing and was not worth a row of its own: `Bash ×3
613
- * denied` beside `Bash ×38` was half the noise. It rides the row instead. */
614
- if (e.refused) {
615
- entry.refused += 1;
616
- entry.node.classList.add("warn");
617
- entry.warnEl.hidden = false;
618
- entry.warnEl.textContent = "⚠ " + plural(entry.refused, "refusal");
619
- }
620
628
  /* Animated where it sits, never hoisted. Hoisting the most recently counted
621
629
  * row would rank a busy week-old session above a quiet one from this
622
630
  * morning: during a backfill, "counted just now" is a fact about the drain
@@ -656,14 +664,19 @@ function place(entry) {
656
664
 
657
665
  function newRow(e, signature) {
658
666
  const parts = evParts(e);
659
- const kind = KIND_TAG[e.kind] || KIND_TAG.unresolved;
667
+ /* A category this build has never heard of takes its own name and the
668
+ * neutral tag: nothing on this page knows enough about it to warn. */
669
+ const kind = KIND_TAG[e.kind] || [e.kind || "", "kt-tool"];
660
670
  /* The placeholder goes as soon as there is a real row — otherwise it rides
661
671
  * the list and counts against the trim. */
662
672
  if (!feedOrder.length) activityFeed.innerHTML = "";
663
673
 
674
+ /* No row this feed builds is reddened. The two sources of it were a call
675
+ * the correlator could not place — which no longer reaches the feed — and a
676
+ * refusal, which `outcome.py` is explicit is a guardrail working as
677
+ * intended: painting it red reports the permission prompt as the incident. */
664
678
  const node = document.createElement("div");
665
679
  node.className = "ev fresh";
666
- if (e.unplaced || e.refused) node.classList.add("warn");
667
680
 
668
681
  const countEl = element("span", "evcount", "");
669
682
  countEl.hidden = true;
@@ -674,16 +687,6 @@ function newRow(e, signature) {
674
687
  first.appendChild(element("span", "evact", parts.action));
675
688
  first.appendChild(countEl);
676
689
 
677
- const warnEl = element("span", "ev-warn", "");
678
- warnEl.hidden = true;
679
- if (e.unplaced) {
680
- warnEl.hidden = false;
681
- warnEl.textContent = "unplaced";
682
- } else if (e.refused) {
683
- warnEl.hidden = false;
684
- warnEl.textContent = e.kind === "tool" ? "⚠ 1 refusal" : "refused";
685
- }
686
-
687
690
  const lastEl = element("span", "evlast", "");
688
691
  const timeEl = element("span", "ev-time", fmtAgo(e.at));
689
692
 
@@ -692,15 +695,14 @@ function newRow(e, signature) {
692
695
  second.appendChild(element("span", "ev-prov", parts.provenance));
693
696
  second.appendChild(lastEl);
694
697
  second.appendChild(timeEl);
695
- second.appendChild(warnEl);
696
698
 
697
699
  node.appendChild(first);
698
700
  node.appendChild(second);
699
701
 
700
702
  const started = Date.parse(e.at);
701
703
  const entry = {
702
- node: node, countEl: countEl, warnEl: warnEl, lastEl: lastEl, timeEl: timeEl,
703
- count: 1, refused: e.refused ? 1 : 0, key: keyOf(e), signature: signature,
704
+ node: node, countEl: countEl, lastEl: lastEl, timeEl: timeEl,
705
+ count: 1, key: keyOf(e), signature: signature,
704
706
  first: Number.isNaN(started) ? Infinity : started,
705
707
  last: Number.isNaN(started) ? -Infinity : started,
706
708
  };
@@ -72,17 +72,11 @@
72
72
  <span class="panel-note">read from this machine's own transcripts</span>
73
73
  </div>
74
74
 
75
- <div class="legend" id="live-legend">
76
- <span><b class="lg-conn">MCP</b> = a link to an outside service</span>
77
- <span><b class="lg-ok">&#10003;</b> resolved to something you installed</span>
78
- <span><b class="lg-warn">&#9888;</b> unresolved &mdash; nothing on this machine claims it</span>
79
- </div>
80
-
81
75
  <div class="logs">
82
76
  <section class="logcol">
83
77
  <header class="logcol-head">
84
78
  <span class="logcol-dot"></span>
85
- <h3>Agent Session Log Trace</h3>
79
+ <h3>Agent Session Trace</h3>
86
80
  <span class="logcol-sub">every tool, skill &amp; connector your sessions used</span>
87
81
  </header>
88
82
  <div class="feed" id="activity"><div class="feed-empty">Watching for agent activity&hellip;</div></div>
@@ -294,11 +294,6 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
294
294
  .comp-chip.hot { border-color:var(--coral); color:var(--coral-d); background:color-mix(in srgb,var(--coral) 6%,var(--paper)); }
295
295
  .comp-chip.hot b { color:var(--coral-d); }
296
296
 
297
- .legend { display:flex; gap:1.4rem; flex-wrap:wrap; margin:1rem 0 1.2rem; padding:0.6rem 0.9rem;
298
- background:var(--panel); border:1px solid var(--line); border-radius:4px; font-size:0.74rem; color:var(--ink-dim); }
299
- .legend b { font-weight:700; }
300
- .lg-conn { color:var(--coral-d); } .lg-ok { color:var(--ok); } .lg-warn { color:var(--coral-d); }
301
-
302
297
  /* two continuous logs */
303
298
  .logs { display:grid; grid-template-columns:minmax(0,1.5fr) minmax(0,1fr); gap:1.2rem; align-items:start; }
304
299
  @media (max-width:860px){ .logs { grid-template-columns:1fr; } }
@@ -354,10 +354,9 @@ def _mask_token(token: str) -> str:
354
354
  default=False,
355
355
  show_default=True,
356
356
  help="Send flagged sessions to the agent's own CLI for semantic analysis. "
357
- "Off by default here, the inverse of `stacktrace detect`: stage 3 spends "
358
- "the developer's own provider quota and is the one stage that sends "
359
- "session content off this machine, and an unattended run is the wrong "
360
- "place for either to be implicit.",
357
+ "Off by default: stage 3 spends the developer's own provider quota and is "
358
+ "the one stage that sends session content off this machine, and an "
359
+ "unattended run is the wrong place for either to be implicit.",
361
360
  )
362
361
  @click.option(
363
362
  "--budget",
@@ -154,7 +154,13 @@ def _collect_detect_run(
154
154
  escalate=escalate,
155
155
  budget=budget,
156
156
  sample_budget=sample_budget,
157
- cache=VerdictCache(default_directory()) if cache and escalate else None,
157
+ # Not gated on `escalate`. A verdict already paid for is an answer
158
+ # this run has, and `escalate` governs whether new ones may be
159
+ # commissioned, not whether old ones may be read -- the rule
160
+ # `run_detector` states at its cache pass. Gating it here made a
161
+ # scheduled run call a session ungraded that `stacktrace detect`
162
+ # calls graded, off the same cache and with neither one spending.
163
+ cache=VerdictCache(default_directory()) if cache else None,
158
164
  )
159
165
  except ValueError as error:
160
166
  raise SyncError(str(error)) from error
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stacktrace-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
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>
@@ -126,9 +126,10 @@ because it is installed.
126
126
  ## What leaves your machine
127
127
 
128
128
  Two of `detect`'s three stages run entirely locally and need no model or
129
- credential. The third sends flagged sessions to the agent's *own* CLI — the
130
- provider that produced the transcript, never a different one — capped by
131
- `--budget`; `--no-escalate` turns it off and leaves the two local stages.
129
+ credential, and they are the two a bare `detect` runs. The third sends flagged
130
+ sessions to the agent's *own* CLI — the provider that produced the transcript,
131
+ never a different one — and runs only when you pass `--escalate`, capped by
132
+ `--budget`.
132
133
 
133
134
  `sessions` omits prompts, tool arguments and results unless you pass
134
135
  `--include-content`. `monitor` binds to loopback only, refuses a non-loopback
@@ -1,9 +1,9 @@
1
- stacktrace_cli/__init__.py,sha256=wEqCraAEngRIhaLrRbqpgR5EHnxQ-vG2K3YDaP-Lrgc,111
1
+ stacktrace_cli/__init__.py,sha256=78vR-YqB8AYVsKnA_cC7awuK3Q18MU2oWvRKBnQd8mc,111
2
2
  stacktrace_cli/__main__.py,sha256=F_tqj3PRzeBtY-uvBs923H8oc-s9bebhIh6UnAUwNYc,448
3
3
  stacktrace_cli/analysis.py,sha256=PECyhd_398fDVjg3K78HVzB4xHYS7sfLNXpASRXNRsY,15303
4
- stacktrace_cli/cli.py,sha256=tJjGtGlaI6OB4bdlgX3q63VffZxRIjwiCwH54kzxRAo,17032
4
+ stacktrace_cli/cli.py,sha256=88FXhQxvOhmd6zHveNUfbuZ-k3xRHVPfLbO0rKtN8Kc,17617
5
5
  stacktrace_cli/correlate/__init__.py,sha256=SexF0pF83OGfBW_569yoyKG94-tb-jGr_ih1q4fHFgE,81
6
- stacktrace_cli/correlate/acquire.py,sha256=09Yxb9u0MLe-j6gGPoG8wPXUb7bLXcyVrnH_EJIBIEY,16289
6
+ stacktrace_cli/correlate/acquire.py,sha256=gqXx_CIZ4hw5GeYfI1f6f5uUFMfA1CO2JdMD8LpEZOc,24249
7
7
  stacktrace_cli/correlate/composition.py,sha256=ZFP3qPOjZvrGVexGWaH7Iq0lIyQLEY-1YxOZ3iMZIbk,20503
8
8
  stacktrace_cli/correlate/join.py,sha256=iyjjmAQoMz6tcISzovQZAqnWA3kZO-Wfbo0ZrmYfquo,17187
9
9
  stacktrace_cli/correlate/observed.py,sha256=MCF3b_oCjebHtTb63KQR7w1sO0-jun78CJ00QAUSsXo,86294
@@ -19,7 +19,7 @@ stacktrace_cli/detector/finding.py,sha256=Nshe6w26VyumGXnyzrh48WWmJ-zpR08J3Lo3z-
19
19
  stacktrace_cli/detector/markers.py,sha256=PUmNitbX4H6l29v-x1FiXohrf2UIPvGs0VnbcHF5E-U,7017
20
20
  stacktrace_cli/detector/priors.py,sha256=sRF7kLr_q-Tma9RG96xwSaV_CpDLFEVmqUMo_LUtVrg,22180
21
21
  stacktrace_cli/detector/reasoning.py,sha256=6E36BtWoWpSEAXtCr7Tv3h3HghytghWEDeKkdAk0dMA,54792
22
- stacktrace_cli/detector/render.py,sha256=Wl-8Ji5DkRI4pCVWqJoHM6eFFl1NB6fM5gCAKQOIN5g,16250
22
+ stacktrace_cli/detector/render.py,sha256=_P5r399R6skopx5cF2DYyUNufHi-IMoX2Q-UT5WBuUE,16240
23
23
  stacktrace_cli/detector/rules.py,sha256=xU2mXP9BTEknwvwVAPJOF7szWAi2w4R57txCuhSIWdM,6046
24
24
  stacktrace_cli/detector/run.py,sha256=BMp9FpEBpciZGPzHHabk5nXSR_ecp4z44v9P7fd4yH0,37832
25
25
  stacktrace_cli/detector/secrets.py,sha256=x1pMUkTjT213TGvaBruRW3Tzvt_wShEjGBGjxgCdMO4,9092
@@ -32,20 +32,20 @@ stacktrace_cli/detector/prompts/v1/stacktrace-injected-instruction-followed.md,s
32
32
  stacktrace_cli/detector/prompts/v1/stacktrace-intent-drift.md,sha256=1dI4bUIonwm6t2x32GnV-yGNfb1jGibeBo6Wli62WZI,455
33
33
  stacktrace_cli/monitor/__init__.py,sha256=akhxzlSGyvbG-3WyC-5Ch5nuYP1E6xga8rzkJ6Tn-AU,469
34
34
  stacktrace_cli/monitor/escalate.py,sha256=tpkp88xKyHX0BRVFow8b9_8YswduAAofidMiZBpRix4,7398
35
- stacktrace_cli/monitor/render.py,sha256=0Wb3Q6y6VRo6GCkULuyqvC7huzCdOejyjeMInxz1DTg,9835
35
+ stacktrace_cli/monitor/render.py,sha256=oR3Sr2Tlk2_NtpVmJfLnU_IH-bkXIs7p4a27OZgn4mY,10064
36
36
  stacktrace_cli/monitor/server.py,sha256=ZTKUy3Ct-7rfsErN3Cpr5PaaZ5SX_OMw-ExFe6FKL9w,19683
37
37
  stacktrace_cli/monitor/state.py,sha256=UVTLpAecypsLTBs5IdWlY55dyJTf-2jI8lDAWoH9PP4,4157
38
38
  stacktrace_cli/monitor/verdicts.py,sha256=V6IK2C9eVxZ2RXjWkfDdmfvN8f-Ol67U-N5c1kXx8rQ,2275
39
39
  stacktrace_cli/monitor/watch.py,sha256=LrbS6RnZg322tbESQ4u2rTVxhkxsNI5UwdhpTEO3GgE,15546
40
- stacktrace_cli/monitor/site/app.js,sha256=zk_dVPLp174NTaHcBi0SEQGkGxl4fiOIf4Y77MHnKKc,43668
41
- stacktrace_cli/monitor/site/index.html,sha256=7zOdxjo6GkfaG56kwvP8Zm66ITyO-Wadn6kO99qVpDM,4169
42
- stacktrace_cli/monitor/site/styles.css,sha256=ol_sSrn2Jwk5xAtz9Avq2itUp9r1Z-nv6sf7KSwNSlU,35603
40
+ stacktrace_cli/monitor/site/app.js,sha256=CM28lPaxuy-oourHb85jw35NDhqnFpYxc0bKgQsLrX0,43898
41
+ stacktrace_cli/monitor/site/index.html,sha256=eLjoYpbXy4cg8hl6l5NdRRWgIVN7UaWUN5-KF94zvM8,3856
42
+ stacktrace_cli/monitor/site/styles.css,sha256=3V7lXe3H0qda5rIWo_95SkQaK1Tlbzb92dk-YaTqDrc,35257
43
43
  stacktrace_cli/monitor/site/fonts/OFL.txt,sha256=3eGN4fg4dKG6QBHOpLJobIeUAs3eQnmZBiykH5b-TGY,9975
44
44
  stacktrace_cli/monitor/site/fonts/dm-mono-400-latin.woff2,sha256=_XUh81MaXM_GVbJcTyLphx3z7BQa15uyf94g0N80e20,8688
45
45
  stacktrace_cli/monitor/site/fonts/dm-mono-500-latin.woff2,sha256=DiY9tSeXCG52NnnFT4Te2MwSSYebwn3KK9XdRG9tnzY,8724
46
46
  stacktrace_cli/monitor/site/fonts/dm-sans-latin.woff2,sha256=qlMHFrDTUYZq99v6Pu5BIPs28tBxuv-MI0GFFBhlx_8,62556
47
47
  stacktrace_cli/remote/__init__.py,sha256=Ba5yZEovOFNDWfgj0ycJkvglUTLN3C47RUQyX-r4opI,82
48
- stacktrace_cli/remote/cli.py,sha256=OooYzF0eQNs_OSsJhA3g2uXFFeYLZx3f5vgnMATOJ9U,17113
48
+ stacktrace_cli/remote/cli.py,sha256=0yeCj9vJTXSYb41AMJ0Tciic8yu6r5WHyuXRy2mW1Xg,17065
49
49
  stacktrace_cli/remote/client.py,sha256=Az1a_CsTvNx9V38iGWKt-fGHN23P1HAgNofrXBQzDFE,13602
50
50
  stacktrace_cli/remote/config.py,sha256=xBTLYfY2OX_JieeIYe3ZCrQqzk2-xKZ8MJfJDXDRlxY,4256
51
51
  stacktrace_cli/remote/detect_payload.py,sha256=5glNKigGWiozh5VviS_fxat-_njDYUlpGJSDQnGteqI,14902
@@ -54,14 +54,14 @@ stacktrace_cli/remote/policy.py,sha256=0pDZcPavCTXvul1Zs6oi7-J3K0YoXPNFa3j1aOJG3
54
54
  stacktrace_cli/remote/redact.py,sha256=cgCMcb2f7Pd7Xt7Ofo6QxFX-fh1Nph-Rb_iiP9UlLjw,29354
55
55
  stacktrace_cli/remote/spool.py,sha256=AI3EVrVk-cIbZ8_JgCTZe0yhdBxoUQ1qvwhXOgsADeo,20823
56
56
  stacktrace_cli/remote/sync.py,sha256=YmOJiWa8rjrbDLJkQ6cZpC6DF1amv83a8_DhnXUB368,25891
57
- stacktrace_cli/remote/sync_detect.py,sha256=dcrdlfF1vbE0BskzjK0idCWLsovBy6SUJ-Yx5ugBbVM,18565
57
+ stacktrace_cli/remote/sync_detect.py,sha256=eLkK5dBEJXi8D_1fGOQvHxfB7Nd1itOE9QsYXJB8Kvo,19011
58
58
  stacktrace_cli/remote/upload_contract.py,sha256=Jz5icyBHXlImbXTetmQhhXbsma0VCKTLpnlKOe6HDLU,44945
59
59
  stacktrace_cli/sessions/__init__.py,sha256=mWb3Z42sN_UqxFQCPpXpsa7Nqe8J9Vd3jspItXqtTmI,76
60
60
  stacktrace_cli/sessions/access.py,sha256=mpKMdRufPGTZNJCVPa2MoKWdM8eOYzaYMDsGqob0De0,3401
61
61
  stacktrace_cli/sessions/outcome.py,sha256=IQUrOoigPAFHnoA26LlOjSSB8hnSQUIJ8E5tQ0LdUJk,2632
62
62
  stacktrace_cli/sessions/protocols.py,sha256=rvQsVd77AekNGhp73masMa61-DbG62QF8Y6hDSwQ9bU,6630
63
63
  stacktrace_cli/sessions/render.py,sha256=SCu9Fx-7hWQWOsy3O7zbYzJqCNomaMp_19V7NQTkSm0,14001
64
- stacktrace_cli-0.2.1.dist-info/METADATA,sha256=vBb-yx_T0sV9KWDkoqQo-NDg7uwQBqC8yMZo32WIoyo,5459
65
- stacktrace_cli-0.2.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
66
- stacktrace_cli-0.2.1.dist-info/entry_points.txt,sha256=OYDmb2CtEjd8TV78zxiGB1XVrLrC6vvayAPXa79_tJ0,60
67
- stacktrace_cli-0.2.1.dist-info/RECORD,,
64
+ stacktrace_cli-0.2.3.dist-info/METADATA,sha256=U3fPKJ8R63wyoqnLfUIcqjU8QdluSlnqnApmv6ccaAU,5482
65
+ stacktrace_cli-0.2.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
66
+ stacktrace_cli-0.2.3.dist-info/entry_points.txt,sha256=OYDmb2CtEjd8TV78zxiGB1XVrLrC6vvayAPXa79_tJ0,60
67
+ stacktrace_cli-0.2.3.dist-info/RECORD,,