stacktrace-cli 0.1.0__py3-none-any.whl → 0.2.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.
Files changed (34) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/analysis.py +375 -0
  3. stacktrace_cli/cli.py +174 -18
  4. stacktrace_cli/correlate/acquire.py +5 -9
  5. stacktrace_cli/correlate/composition.py +59 -0
  6. stacktrace_cli/correlate/join.py +48 -2
  7. stacktrace_cli/correlate/orchestrate.py +37 -3
  8. stacktrace_cli/correlate/record.py +8 -0
  9. stacktrace_cli/detector/deterministic.py +95 -47
  10. stacktrace_cli/detector/finding.py +111 -2
  11. stacktrace_cli/detector/render.py +62 -2
  12. stacktrace_cli/detector/run.py +385 -17
  13. stacktrace_cli/monitor/__init__.py +12 -0
  14. stacktrace_cli/monitor/escalate.py +172 -0
  15. stacktrace_cli/monitor/render.py +252 -0
  16. stacktrace_cli/monitor/server.py +477 -0
  17. stacktrace_cli/monitor/site/app.js +1045 -0
  18. stacktrace_cli/monitor/site/fonts/OFL.txt +210 -0
  19. stacktrace_cli/monitor/site/fonts/dm-mono-400-latin.woff2 +0 -0
  20. stacktrace_cli/monitor/site/fonts/dm-mono-500-latin.woff2 +0 -0
  21. stacktrace_cli/monitor/site/fonts/dm-sans-latin.woff2 +0 -0
  22. stacktrace_cli/monitor/site/index.html +104 -0
  23. stacktrace_cli/monitor/site/styles.css +628 -0
  24. stacktrace_cli/monitor/state.py +102 -0
  25. stacktrace_cli/monitor/verdicts.py +52 -0
  26. stacktrace_cli/monitor/watch.py +349 -0
  27. stacktrace_cli/remote/detect_payload.py +99 -27
  28. stacktrace_cli/remote/redact.py +13 -2
  29. stacktrace_cli/remote/sync_detect.py +79 -42
  30. stacktrace_cli/remote/upload_contract.py +21 -0
  31. {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/METADATA +1 -1
  32. {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/RECORD +34 -19
  33. {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/WHEEL +0 -0
  34. {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/entry_points.txt +0 -0
@@ -115,6 +115,17 @@ class Composition:
115
115
  #: Where it was scanned instead. Set together with `relocated_from`; the pair
116
116
  #: is what makes the inference auditable rather than merely disclosed.
117
117
  relocated_to: str | None = None
118
+ #: `openaca:agent_id` off the BOM root: *which* agent, where `agent_kind`
119
+ #: says *what*. Absent for a singleton kind — every kind OpenACA registers,
120
+ #: whose lint refuses an id outright — so `None` is the ordinary case and
121
+ #: not a gap.
122
+ #:
123
+ #: Carried because the hosted side keys an agent on it (issue #12): a BOM
124
+ #: upload lets the Cloud read this off the root itself, while a detect run
125
+ #: sends no BOM and must state it, and the two naming different things files
126
+ #: one agent as two rows. Defaulted rather than positional so it sits with
127
+ #: the fields a document may omit, though it belongs beside `agent_kind`.
128
+ agent_instance_id: str | None = None
118
129
 
119
130
  def find(self, component_type: str, name: str) -> tuple[Component, ...]:
120
131
  """Every component of this type and name, in BOM order.
@@ -169,9 +180,57 @@ def composition_from_bom(
169
180
  lineage=lineage,
170
181
  advisories=advisories or {},
171
182
  advisories_checked=advisories is not None,
183
+ agent_instance_id=_declared_instance_id(document),
172
184
  )
173
185
 
174
186
 
187
+ #: `openaca:agent_id` on `metadata.component`. Read here rather than in
188
+ #: `acquire` so the projection that owns every other root field owns this one.
189
+ _AGENT_ID_PROPERTY = "openaca:agent_id"
190
+
191
+
192
+ def _declared_instance_id(document: dict[str, Any]) -> str | None:
193
+ """`openaca:agent_id`, blank-checked but **never rewritten**.
194
+
195
+ Blank folds to absent because the Cloud's `agent_instance_id` is
196
+ `min_length=1`: a present-but-empty value is a 422 that rejects the *whole*
197
+ detect run, every detection in it. OpenACA's lint already refuses an empty
198
+ id, so only a hand-written document arrives with one, and losing that BOM's
199
+ id costs less than losing the machine's detections. The two stay
200
+ distinguishable where it counts -- the hosted side still rejects an empty
201
+ string; this simply never sends one.
202
+
203
+ **Whitespace is not trimmed.** The Cloud reads this same property off the
204
+ same root on the BOM upload path and does not trim it either, so
205
+ normalising here would leave the two paths naming different strings for one
206
+ agent -- which is the split (issue #12) reintroduced for a padded value. A
207
+ padded id already satisfies `min_length=1`, so there is nothing to fix by
208
+ rewriting it.
209
+ """
210
+ declared = root_property(document, _AGENT_ID_PROPERTY)
211
+ return declared if declared and declared.strip() else None
212
+
213
+
214
+ def root_property(document: dict[str, Any], name: str) -> str | None:
215
+ """One `metadata.component` property's value, or `None`.
216
+
217
+ Public because `acquire` reads the agent kind out of a raw document before
218
+ it has a composition to ask -- the discriminator has to be known to pick
219
+ which kind a document is even for. Non-`str` reads as absent for the reason
220
+ `reject_malformed_shape` exists: a value from a document this process did
221
+ not write is validated, never trusted for its type.
222
+ """
223
+ metadata = document.get("metadata")
224
+ root = metadata.get("component") if isinstance(metadata, dict) else None
225
+ if not isinstance(root, dict):
226
+ return None
227
+ for prop in root.get("properties") or []:
228
+ if isinstance(prop, dict) and prop.get("name") == name:
229
+ value = prop.get("value")
230
+ return value if isinstance(value, str) else None
231
+ return None
232
+
233
+
175
234
  #: Where a BOM's shape is checked before anything reads it.
176
235
  #:
177
236
  #: Every malformed-document finding this module has ever taken was the same
@@ -256,6 +256,28 @@ def candidates_for(key: JoinKey, composition: Composition) -> tuple[Component, .
256
256
  return candidates
257
257
 
258
258
 
259
+ def builtin_category_and_name(call: ToolCallLike, agent_kind: str) -> tuple[str, str] | None:
260
+ """What a built-in resolution was, re-asked rather than read.
261
+
262
+ A built-in's `Resolution` is `key=None, candidates=(), target=root` and is
263
+ byte-identical for a tool, a subagent and a skill, so the category and the
264
+ name are both unrecoverable from it. The predicates above are the one
265
+ implementation of that question, and this is the one place that composes
266
+ them -- it lives here, beside them, because both the wire's activity
267
+ attribution and a finding's own subject have to give the same answer, and
268
+ two copies is how they would come to differ.
269
+ """
270
+ if is_builtin_subagent(call, agent_kind):
271
+ requested = (call.arguments or {}).get("subagent_type")
272
+ return "subagent", str(requested)
273
+ if is_builtin(call.tool_name, agent_kind):
274
+ return "tool", call.tool_name
275
+ key = join_key(call)
276
+ if key is not None and is_builtin_skill(key, agent_kind):
277
+ return "skill", key.name
278
+ return None
279
+
280
+
259
281
  def join_key(call: ToolCallLike) -> JoinKey | None:
260
282
  """What this call names, or None if this component cannot key it.
261
283
 
@@ -263,8 +285,32 @@ def join_key(call: ToolCallLike) -> JoinKey | None:
263
285
  recognized shape whose expected argument was missing, and a tool this
264
286
  component does not recognize at all.
265
287
  """
288
+ addressed = addressed_naming(call)
289
+ if addressed is None:
290
+ return None
291
+ component_type, name = addressed
292
+ # A server's name is what the client recorded about where the call went;
293
+ # only a name read out of an argument is the session's own spelling and so
294
+ # the only one there is anything to normalize away.
295
+ return JoinKey(component_type, name if call.mcp_server else _unqualified(name))
296
+
297
+
298
+ def addressed_naming(call: ToolCallLike) -> tuple[str, str] | None:
299
+ """The component type this call named, and the word it named it by.
300
+
301
+ The same extraction `join_key` keys on, read before normalization and
302
+ returned as one answer so the two cannot come to disagree about which
303
+ calls name anything at all.
304
+
305
+ The word matters where nothing placed the call: `join_key` drops a plugin
306
+ qualifier so a session's `superpowers:brainstorming` can match a BOM's
307
+ `brainstorming`, which is the right question to ask a composition and the
308
+ wrong name to report back. Two plugins may ship a skill of one name, so
309
+ the qualifier is the part that says which one ran, and ADR-0027 carries
310
+ the name the session addressed rather than the key correlation looked up.
311
+ """
266
312
  if call.mcp_server:
267
- return JoinKey("mcp_server", call.mcp_server)
313
+ return "mcp_server", call.mcp_server
268
314
  signal = _ARGUMENT_SIGNALS.get(call.tool_name)
269
315
  if signal is None:
270
316
  return None
@@ -272,7 +318,7 @@ def join_key(call: ToolCallLike) -> JoinKey | None:
272
318
  value = (call.arguments or {}).get(argument)
273
319
  if not isinstance(value, str) or not value:
274
320
  return None
275
- return JoinKey(component_type, _unqualified(value))
321
+ return component_type, value
276
322
 
277
323
 
278
324
  def _unqualified(name: str) -> str:
@@ -34,12 +34,14 @@ now imports from here, which is the direction it should always have been.
34
34
  from __future__ import annotations
35
35
 
36
36
  from collections.abc import Callable, Sequence
37
+ from collections.abc import Set as AbstractSet
37
38
  from concurrent.futures import ThreadPoolExecutor, as_completed
38
39
  from dataclasses import dataclass, replace
39
40
  from pathlib import Path
41
+ from typing import Any
40
42
 
41
43
  from stacktrace_cli.correlate.acquire import Built, advisories_for, build_bom, load_bom
42
- from stacktrace_cli.correlate.composition import Composition
44
+ from stacktrace_cli.correlate.composition import Advisory, Composition
43
45
  from stacktrace_cli.correlate.join import is_builtin, is_builtin_subagent, join_key
44
46
  from stacktrace_cli.correlate.project_map import ProjectMapping, remap
45
47
  from stacktrace_cli.correlate.record import CorrelatedView, composition_key
@@ -155,7 +157,39 @@ def _invoked_identities(correlated: object) -> set[str]:
155
157
  return identities
156
158
 
157
159
 
158
- def _attach_advisories(correlated: CorrelatedView, builts: Sequence[Built]) -> CorrelatedView:
160
+ #: `advisories_for`'s shape: the documents to search and the identities that
161
+ #: were actually invoked, answering `None` for "not checked at all", which must
162
+ #: stay distinguishable from "checked and found nothing".
163
+ AdvisoryLookup = Callable[
164
+ ["Sequence[dict[str, Any]]", "AbstractSet[str]"],
165
+ "dict[str, tuple[Advisory, ...]] | None",
166
+ ]
167
+ AttachAdvisories = Callable[[CorrelatedView, "Sequence[Built]"], CorrelatedView]
168
+
169
+
170
+ def advisory_attacher(lookup: AdvisoryLookup = advisories_for) -> AttachAdvisories:
171
+ """`_attach_advisories`, with the network call substitutable.
172
+
173
+ A long-lived process — `monitor` — asks this question every time a
174
+ transcript changes, and the answer for a component it already asked about
175
+ has not changed. Only the *lookup* is swappable: the enrichment below stays
176
+ in one place because its memo by `id(composition)` is load-bearing for
177
+ every consumer that dedupes compositions by identity, and a second copy of
178
+ that reasoning would be a second chance to get it wrong.
179
+ """
180
+
181
+ def attach(correlated: CorrelatedView, builts: Sequence[Built]) -> CorrelatedView:
182
+ return _attach_advisories(correlated, builts, lookup=lookup)
183
+
184
+ return attach
185
+
186
+
187
+ def _attach_advisories(
188
+ correlated: CorrelatedView,
189
+ builts: Sequence[Built],
190
+ *,
191
+ lookup: AdvisoryLookup = advisories_for,
192
+ ) -> CorrelatedView:
159
193
  """Match advisories once, over what ran, and hang them on every composition.
160
194
 
161
195
  The scan is deferred to here because this is the first moment the question
@@ -177,7 +211,7 @@ def _attach_advisories(correlated: CorrelatedView, builts: Sequence[Built]) -> C
177
211
  two different enriched objects and break that dedup silently.
178
212
  """
179
213
  identities = _invoked_identities(correlated)
180
- matched = advisories_for([b.document for b in builts], identities)
214
+ matched = lookup([b.document for b in builts], identities)
181
215
  checked = matched is not None
182
216
  by_identity = matched or {}
183
217
  memo: dict[int, Composition] = {}
@@ -269,6 +269,14 @@ def _coverage(session: SessionLike, resolutions: dict[str, Resolution]) -> Cover
269
269
  resolved += 1
270
270
  # An ambiguous match counts once, under its key's type: the call happened
271
271
  # once, however many components it might name.
272
+ #
273
+ # It counts as **resolved** here and is sent as *unresolved* on the
274
+ # wire (`remote/detect_payload.classify_call`), so this number and the
275
+ # Cloud's recomputed coverage legitimately differ by the ambiguous
276
+ # calls. Deliberate in that direction: coverage summed from per-
277
+ # component counters has no honest bucket for a call that named two, so
278
+ # the wire errs toward under-reporting. See the detect-upload spec,
279
+ # *Ambiguity is named in a finding, never in a counter*.
272
280
  if resolution.key is not None:
273
281
  by_component_type[resolution.key.component_type] += 1
274
282
  return Coverage(
@@ -37,6 +37,7 @@ from __future__ import annotations
37
37
 
38
38
  import json
39
39
  import re
40
+ from dataclasses import dataclass
40
41
 
41
42
  from stacktrace_cli.correlate.observed import (
42
43
  capabilities,
@@ -454,6 +455,94 @@ def _hung_call(session: SessionLike, ref: SessionRef) -> Detection | None:
454
455
  )
455
456
 
456
457
 
458
+ @dataclass(frozen=True)
459
+ class Scavenging:
460
+ """One server's refusal, the later success, and what that success added.
461
+
462
+ `acquired` is keyed on `(kind, fingerprint)` and is the whole trigger: the
463
+ credentials the agent did not carry to this server through its refusal.
464
+ Whatever else a reader is told about the sequence has to be about exactly
465
+ these, so the set is computed once and handed out rather than re-derived
466
+ per question -- a second derivation is free to disagree, and a claim about
467
+ where a credential came from that disagrees with the claim about which
468
+ credential it was is false however carefully either half was written.
469
+ """
470
+
471
+ refusal: ToolCallLike
472
+ success: ToolCallLike
473
+ acquired: frozenset[tuple[str, str]]
474
+
475
+
476
+ def _scavenging(calls: list[ToolCallLike]) -> Scavenging | None:
477
+ """The scavenging one server's calls record, if they record one."""
478
+ # An **explicit** refusal, never a missing outcome, and never a
479
+ # `rejected` one either. `pending` was in this set and is the record's
480
+ # dominant artefact -- 7,242 of 29,176 calls, because upstream withholds
481
+ # an outcome it could not establish -- so "did not succeed" was
482
+ # satisfied by any server whose result was merely unreadable.
483
+ # `rejected` looked like the server's own refusal but is the same fact
484
+ # `observe()` and every priors rule already act on: a rejected call was
485
+ # denied by the person before it ever
486
+ # reached the server, so it is a guardrail working as intended, not a
487
+ # component turning the agent away. Only a stated `error` is that.
488
+ #
489
+ # This narrowing cost nothing on the measured corpus and was not
490
+ # prompted by a false positive there: all four firings on 621 sessions
491
+ # are the planted scenarios this rule's own test fixtures supply, and
492
+ # each of those refuses with `error`. It closes a shape
493
+ # that had no evidence behind it rather than one that had too much.
494
+ first_failure = next((i for i, c in enumerate(calls) if failed(c)), None)
495
+ if first_failure is None:
496
+ return None
497
+ # Keyed on (kind, fingerprint), not kind alone: an issuer's refused
498
+ # credential replaced by a *different* credential of the same kind --
499
+ # the central recovery this rule exists to catch -- shares a kind with
500
+ # what was already held, and kind-only dedup silently cleared it.
501
+ # `fingerprint` is a one-way hash, so this comparison never holds or
502
+ # emits the matched value itself.
503
+ held = {
504
+ (match.kind, match.fingerprint)
505
+ for c in calls[: first_failure + 1]
506
+ for match in find_secrets(c.arguments)
507
+ }
508
+ carrying = []
509
+ for i, c in enumerate(calls):
510
+ if i <= first_failure or not succeeded(c):
511
+ continue
512
+ acquired = frozenset(
513
+ (match.kind, match.fingerprint)
514
+ for match in find_secrets(c.arguments)
515
+ if (match.kind, match.fingerprint) not in held
516
+ )
517
+ if acquired:
518
+ carrying.append((c, acquired))
519
+ if not carrying:
520
+ return None
521
+ call, acquired = carrying[-1]
522
+ return Scavenging(refusal=calls[first_failure], success=call, acquired=acquired)
523
+
524
+
525
+ def scavenging_anchored_on(session: SessionLike, refusal_span: str) -> Scavenging | None:
526
+ """The scavenging a `stacktrace-scavenged-credential` finding is about.
527
+
528
+ Read from the record by the same code the rule fired from, so anything
529
+ added to such a finding after the fact -- where those credentials came
530
+ from, above all -- is about the credentials that caused it and not about
531
+ every credential its cited calls happen to carry. The refused call already
532
+ carrying a credential of its own is the ordinary case, not a corner: that
533
+ credential has a source, and that source is not a party to the agent then
534
+ going looking for another one.
535
+ """
536
+ calls = [call for turn in session.turns for call in turn.tool_calls]
537
+ refused = next((call for call in calls if call.span == refusal_span), None)
538
+ if refused is None or not refused.mcp_server:
539
+ return None
540
+ scavenging = _scavenging([c for c in calls if c.mcp_server == refused.mcp_server])
541
+ if scavenging is None or scavenging.refusal.span != refusal_span:
542
+ return None
543
+ return scavenging
544
+
545
+
457
546
  def _scavenged_credential(session: SessionLike, ref: SessionRef) -> tuple[Detection, ...]:
458
547
  """A component that rejected the agent, then accepted it carrying a secret.
459
548
 
@@ -474,52 +563,11 @@ def _scavenged_credential(session: SessionLike, ref: SessionRef) -> tuple[Detect
474
563
  calls = [
475
564
  call for turn in session.turns for call in turn.tool_calls if call.mcp_server == server
476
565
  ]
477
- # An **explicit** refusal, never a missing outcome, and never a
478
- # `rejected` one either. `pending` was in this set and is the record's
479
- # dominant artefact -- 7,242 of 29,176 calls, because upstream withholds
480
- # an outcome it could not establish -- so "did not succeed" was
481
- # satisfied by any server whose result was merely unreadable.
482
- # `rejected` looked like the server's own refusal but is the same fact
483
- # `observe()` and every priors rule already act on: a rejected call was
484
- # denied by the person before it ever
485
- # reached the server, so it is a guardrail working as intended, not a
486
- # component turning the agent away. Only a stated `error` is that.
487
- #
488
- # This narrowing cost nothing on the measured corpus and was not
489
- # prompted by a false positive there: all four firings on 621 sessions
490
- # are the planted scenarios this rule's own test fixtures supply, and
491
- # each of those refuses with `error`. It closes a shape
492
- # that had no evidence behind it rather than one that had too much.
493
- first_failure = next((i for i, c in enumerate(calls) if failed(c)), None)
494
- if first_failure is None:
495
- continue
496
- # Keyed on (kind, fingerprint), not kind alone: an issuer's refused
497
- # credential replaced by a *different* credential of the same kind --
498
- # the central recovery this rule exists to catch -- shares a kind with
499
- # what was already held, and kind-only dedup silently cleared it.
500
- # `fingerprint` is a one-way hash, so this comparison never holds or
501
- # emits the matched value itself.
502
- held = {
503
- (match.kind, match.fingerprint)
504
- for c in calls[: first_failure + 1]
505
- for match in find_secrets(c.arguments)
506
- }
507
- carrying = []
508
- for i, c in enumerate(calls):
509
- if i <= first_failure or not succeeded(c):
510
- continue
511
- acquired = sorted(
512
- {
513
- match.kind
514
- for match in find_secrets(c.arguments)
515
- if (match.kind, match.fingerprint) not in held
516
- }
517
- )
518
- if acquired:
519
- carrying.append((c, acquired))
520
- if not carrying:
566
+ scavenging = _scavenging(calls)
567
+ if scavenging is None:
521
568
  continue
522
- call, kinds = carrying[-1]
569
+ call = scavenging.success
570
+ kinds = sorted({kind for kind, _ in scavenging.acquired})
523
571
  found.append(
524
572
  Detection(
525
573
  rule_id="stacktrace-scavenged-credential",
@@ -530,9 +578,9 @@ def _scavenged_credential(session: SessionLike, ref: SessionRef) -> tuple[Detect
530
578
  observed_capabilities=observed_across(call),
531
579
  evidence=(
532
580
  Evidence(
533
- span=calls[first_failure].span,
581
+ span=scavenging.refusal.span,
534
582
  kind="refused",
535
- detail=f"{_tool(calls[first_failure])} did not succeed",
583
+ detail=f"{_tool(scavenging.refusal)} did not succeed",
536
584
  ),
537
585
  Evidence(
538
586
  span=call.span,
@@ -7,13 +7,19 @@ indistinguishable from *nothing was wrong* and this pipeline may never say that
7
7
  by accident (ADR-0004).
8
8
 
9
9
  **Neither record can carry session content, and that is structural.** There is no
10
- field here able to hold a prompt, an argument, a result, a matched secret or a
11
- decoded payload — only spans, kinds and coordinates. Findings travel further than
10
+ field here able to hold a prompt, a result, a matched secret or a decoded
11
+ payload — only spans, kinds and coordinates. Findings travel further than
12
12
  sessions do: a session never leaves the machine, but a detection may be uploaded
13
13
  agent-keyed (ADR-0006). Putting the boundary in the data model means a future
14
14
  rule author cannot breach it by writing a plausible-looking rule; they would have
15
15
  to add a field first, and a reviewer sees that.
16
16
 
17
+ The one field that is narrower than that is `Participant.name` on a party
18
+ correlation could not place, which ADR-0027 admits *because* it is the only
19
+ name such a thing has and a finding without it names nothing to act on. It is
20
+ one field at one position, carried as a descriptor and never as an identity —
21
+ see `Participant`.
22
+
17
23
  Severity and confidence are this family's own (ADR-0010). `openaca.core` exports
18
24
  no ladder to reuse: its `Finding.confidence` is a bare `str` of
19
25
  `high`/`low`/`unknown`, and severity is reachable only through
@@ -168,6 +174,88 @@ class Coverage:
168
174
  resolved: int = 0
169
175
 
170
176
 
177
+ #: How well a party to a finding can be known, and the whole of it. Closed
178
+ #: because what a participant may *carry* is defined per kind below -- a fifth
179
+ #: spelling would be a name with no rule behind it and no consumer able to
180
+ #: render it as anything.
181
+ PARTICIPANT_KINDS = ("component", "ambiguous", "builtin", "unplaced")
182
+
183
+ #: The parts a participant plays in a finding. `subject` is the anchor the
184
+ #: finding is about; `source` is where the credential it cites came from.
185
+ PARTICIPANT_ROLES = ("subject", "source")
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Participant:
190
+ """One named party to a finding, labelled by **how well it is known**.
191
+
192
+ `components` can only name what correlation *placed* -- a BOM component
193
+ with a verified identity -- so three of the five things a call resolves to
194
+ are unnameable by it and reach a reader as a dash: the agent's own built-in
195
+ tool, a server nothing on the machine declares, and an unplaced skill.
196
+
197
+ `kind` is what stops filling that dash being a regression. An unverified
198
+ name in a column of verified ones reads as inventory, which is worse than a
199
+ dash -- so a consumer must render `builtin` as what it is, and must never
200
+ link it or resolve a lookup by it. `identity` is `None` for every kind but
201
+ `component`, which is the machine-checkable form of the same rule.
202
+
203
+ **What a name is worth differs per kind, and that is a boundary rather
204
+ than a convention.** Three of the four names come from the BOM or from a
205
+ closed set this package owns. The fourth is the session's own word for
206
+ something nothing placed, which ADR-0027 admits as a descriptor and
207
+ nothing more: it may be carried, and it may never be an identity.
208
+ `__post_init__` is what makes that a property of the record rather than a
209
+ habit of today's two producers -- an `unplaced` party cannot be
210
+ constructed with an `identity`, so no later rule or serializer has to
211
+ remember not to give it one.
212
+
213
+ - `component` -- placed, one candidate, `identity` set. `name` is the BOM's.
214
+ - `ambiguous` -- several candidates could be it; `name` is the alias they
215
+ share, read off a candidate, and `identity` is `None` because choosing
216
+ between them is the re-grading correlation refuses to do. The candidates
217
+ are in `components`.
218
+ - `builtin` -- the agent's own tool, subagent or skill. Not installed, so
219
+ not a component, but its name is one of ours -- `join.py` matched it
220
+ against the closed set of what the kind ships -- and a stall on it is a
221
+ real finding.
222
+ - `unplaced` -- called, and absent from every composition. The mirror of
223
+ "installed but never invoked", and the case a responder most needs. Its
224
+ only name is the session's own word for it, and a finding that withholds
225
+ it says a credential left for *a mcp_server* and names nothing to rotate
226
+ (ADR-0027). So the word is carried, `identity` stays `None` so it can
227
+ resolve no lookup and join to no inventory, and a consumer must mark it
228
+ unverified.
229
+ """
230
+
231
+ kind: str
232
+ name: str
233
+ component_type: str | None = None
234
+ identity: str | None = None
235
+ #: `subject` for the finding's own anchor; `source` for a component whose
236
+ #: result carried the credential a later call sent on.
237
+ role: str = "subject"
238
+
239
+ def __post_init__(self) -> None:
240
+ if self.kind not in PARTICIPANT_KINDS:
241
+ raise ValueError(f"{self.kind!r} is not one of {PARTICIPANT_KINDS}")
242
+ if self.role not in PARTICIPANT_ROLES:
243
+ raise ValueError(f"{self.role!r} is not one of {PARTICIPANT_ROLES}")
244
+ # A name is not required of any kind: a placed component whose BOM
245
+ # entry omitted its own name reads as `""` out of `composition.py`,
246
+ # which is a gap in what a reader is told rather than a leak, and
247
+ # refusing it here would turn a thin BOM into a crash mid-run.
248
+ #
249
+ # `identity` is the machine-checkable form of "only a placed component
250
+ # may be resolved by name", and the rule ADR-0027 leans on: an
251
+ # unplaced name is a word one session used, so two machines calling
252
+ # unrelated servers `support` must never join. A component's own
253
+ # identity may be `None` -- not every BOM entry has a verified one --
254
+ # but no other kind may hold one.
255
+ if self.identity is not None and self.kind != "component":
256
+ raise ValueError(f"a {self.kind} participant has no verified identity to carry")
257
+
258
+
171
259
  @dataclass(frozen=True)
172
260
  class Detection:
173
261
  """One claim about one session.
@@ -179,6 +267,19 @@ class Detection:
179
267
  of components would name both ends, or every candidate either end implicates
180
268
  where the resolution was ambiguous — narrowing an ambiguous match to make a
181
269
  finding tidier would be the re-grading correlation refuses to do.
270
+
271
+ **What it means differs by rule, deliberately.** For `advisory-reach` the
272
+ component *is* the finding's subject: the advisory is about it. For a
273
+ behaviour rule — a stall, an egress, a scavenged credential — it is
274
+ context: the components the cited calls resolved to. Both are useful and
275
+ they are not the same claim, so a reader asking "what is this about"
276
+ should read the rule id first and this second.
277
+
278
+ A behaviour rule does not fill this itself; it never sees a composition
279
+ (`run.py`'s `_resolved_components` attaches it at the same seam as
280
+ coverage). So what appears here is only ever what the correlator already
281
+ committed to: a built-in names nothing, an ambiguous match names every
282
+ candidate and chooses none, and with no BOM at all this stays empty.
182
283
  """
183
284
 
184
285
  rule_id: str
@@ -187,6 +288,14 @@ class Detection:
187
288
  title: str
188
289
  session: SessionRef
189
290
  components: tuple[Component, ...] = ()
291
+ #: What this finding was about, named however well it is known -- so a
292
+ #: reader is never left with a dash where the name was in hand. Derived
293
+ #: from the anchor at the same seam as `components`, never by a rule.
294
+ subject: Participant | None = None
295
+ #: The other parties to the same trigger. Today one role: the `source`
296
+ #: whose result carried the credential a later call sent on, because a sink
297
+ #: alone says a secret left and not where it was got.
298
+ involved: tuple[Participant, ...] = ()
190
299
  observed_capabilities: tuple[str, ...] = ()
191
300
  evidence: tuple[Evidence, ...] = ()
192
301
  remediation: str = ""
@@ -23,7 +23,7 @@ _ORDER = {"high": 0, "medium": 1, "low": 2}
23
23
  def render_json(result: DetectorRun) -> str:
24
24
  return json.dumps(
25
25
  {
26
- "detections": [_detection(d) for d in _sorted(result)],
26
+ "detections": [detection_json(d) for d in _sorted(result)],
27
27
  "unknowns": [
28
28
  {
29
29
  "session_id": u.session.session_id,
@@ -56,7 +56,13 @@ def render_json(result: DetectorRun) -> str:
56
56
  )
57
57
 
58
58
 
59
- def _detection(detection) -> dict[str, object]:
59
+ def detection_json(detection) -> dict[str, object]:
60
+ """One detection as the published JSON object.
61
+
62
+ Named rather than private because it has a second consumer: `monitor`
63
+ renders the same finding on the local page, and a dict literal there would
64
+ be a second source for one fact (ADR-0024).
65
+ """
60
66
  verdict = detection.verdict
61
67
  return {
62
68
  "rule_id": detection.rule_id,
@@ -81,6 +87,12 @@ def _detection(detection) -> dict[str, object]:
81
87
  }
82
88
  for c in detection.components
83
89
  ],
90
+ # What the finding was about, and who else was party to it. Carried
91
+ # here as well as on the wire: `--format json` is a machine surface,
92
+ # and omitting it would make the local command and the upload disagree
93
+ # about the same run.
94
+ "subject": _participant_json(detection.subject),
95
+ "involved": [_participant_json(p) for p in detection.involved],
84
96
  "observed_capabilities": list(detection.observed_capabilities),
85
97
  # Kind and detail, never a value. See this module's docstring.
86
98
  "evidence": [
@@ -105,6 +117,45 @@ def _detection(detection) -> dict[str, object]:
105
117
  }
106
118
 
107
119
 
120
+ def _participant_json(participant) -> dict[str, object] | None:
121
+ if participant is None:
122
+ return None
123
+ return {
124
+ "kind": participant.kind,
125
+ "role": participant.role,
126
+ "name": participant.name,
127
+ "component_type": participant.component_type,
128
+ "identity": participant.identity,
129
+ }
130
+
131
+
132
+ #: How a subject that is not a placed component is marked, so a reader never
133
+ #: takes one for inventory. `unplaced` is deliberately not "not installed": the
134
+ #: session called it, so it was there when it ran -- what is missing is an
135
+ #: entry for it in what the scan could see now.
136
+ _QUALIFIERS = {
137
+ "builtin": " (built-in)",
138
+ "unplaced": " (not in inventory)",
139
+ "ambiguous": " (ambiguous)",
140
+ }
141
+
142
+
143
+ def _qualifier(kind: str) -> str:
144
+ return _QUALIFIERS.get(kind, "")
145
+
146
+
147
+ def _named(participant) -> str:
148
+ """How a party to a finding reads on one line.
149
+
150
+ A party with no name at all -- a placed component whose BOM entry omitted
151
+ one -- is named by what it is instead: `a skill (not in inventory)`. The
152
+ bare qualifier on its own would say only that something was missing, which
153
+ is the dash this exists to replace.
154
+ """
155
+ name = participant.name or f"a {participant.component_type or 'component'}"
156
+ return f"{name}{_qualifier(participant.kind)}"
157
+
158
+
108
159
  def render_text(result: DetectorRun, detail: bool = False) -> str:
109
160
  """The summary. The per-detection trail only when asked for.
110
161
 
@@ -152,6 +203,15 @@ def render_text(result: DetectorRun, detail: bool = False) -> str:
152
203
  for component in detection.components:
153
204
  version = f" {component.version}" if component.version else ""
154
205
  lines.append(f" component {component.name}{version}")
206
+ # Only where `components` said nothing. A placed subject is the
207
+ # same thing the line above already named, and printing both would
208
+ # read as two things being involved.
209
+ subject = detection.subject
210
+ if subject is not None and not detection.components:
211
+ lines.append(f" on {_named(subject)}")
212
+ for participant in detection.involved:
213
+ if participant.role == "source":
214
+ lines.append(f" credential from {_named(participant)}")
155
215
  for evidence in detection.evidence:
156
216
  lines.append(f" {evidence.span} {evidence.kind}: {evidence.detail}")
157
217
  if detection.remediation: