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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/analysis.py +375 -0
- stacktrace_cli/cli.py +174 -18
- stacktrace_cli/correlate/acquire.py +5 -9
- stacktrace_cli/correlate/composition.py +59 -0
- stacktrace_cli/correlate/join.py +48 -2
- stacktrace_cli/correlate/orchestrate.py +37 -3
- stacktrace_cli/correlate/record.py +8 -0
- stacktrace_cli/detector/deterministic.py +95 -47
- stacktrace_cli/detector/finding.py +111 -2
- stacktrace_cli/detector/render.py +62 -2
- stacktrace_cli/detector/run.py +385 -17
- stacktrace_cli/monitor/__init__.py +12 -0
- stacktrace_cli/monitor/escalate.py +172 -0
- stacktrace_cli/monitor/render.py +252 -0
- stacktrace_cli/monitor/server.py +477 -0
- stacktrace_cli/monitor/site/app.js +1045 -0
- stacktrace_cli/monitor/site/fonts/OFL.txt +210 -0
- stacktrace_cli/monitor/site/fonts/dm-mono-400-latin.woff2 +0 -0
- stacktrace_cli/monitor/site/fonts/dm-mono-500-latin.woff2 +0 -0
- stacktrace_cli/monitor/site/fonts/dm-sans-latin.woff2 +0 -0
- stacktrace_cli/monitor/site/index.html +104 -0
- stacktrace_cli/monitor/site/styles.css +628 -0
- stacktrace_cli/monitor/state.py +102 -0
- stacktrace_cli/monitor/verdicts.py +52 -0
- stacktrace_cli/monitor/watch.py +349 -0
- stacktrace_cli/remote/detect_payload.py +99 -27
- stacktrace_cli/remote/redact.py +13 -2
- stacktrace_cli/remote/sync_detect.py +79 -42
- stacktrace_cli/remote/upload_contract.py +21 -0
- {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/METADATA +1 -1
- {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/RECORD +34 -19
- {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/WHEEL +0 -0
- {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
|
stacktrace_cli/correlate/join.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
478
|
-
|
|
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
|
|
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=
|
|
581
|
+
span=scavenging.refusal.span,
|
|
534
582
|
kind="refused",
|
|
535
|
-
detail=f"{_tool(
|
|
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,
|
|
11
|
-
|
|
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": [
|
|
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
|
|
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:
|