agentic-transfer-verifier 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ """Agentic transfer verification primitives."""
2
+
3
+ from agentic_transfer_verifier.adversarial import (
4
+ AttackChainCase,
5
+ AttackStep,
6
+ ChainAssessment,
7
+ DetectionSignal,
8
+ assess_attack_chain,
9
+ attack_chain_corpus,
10
+ detection_signal_catalog,
11
+ )
12
+ from agentic_transfer_verifier.asset_leakage import (
13
+ ProtectedAsset,
14
+ SinkAssessment,
15
+ SinkAttempt,
16
+ assess_sink_attempt,
17
+ leakage_scenario_corpus,
18
+ trading_signal_canary,
19
+ )
20
+ from agentic_transfer_verifier.models import (
21
+ CapabilityGrant,
22
+ Finding,
23
+ IdentityClaim,
24
+ ProvenanceStep,
25
+ TransferEdge,
26
+ TransferEnvelope,
27
+ TransferRisk,
28
+ TransferRiskProfile,
29
+ VerificationReport,
30
+ )
31
+ from agentic_transfer_verifier.portfolio_adapter import (
32
+ AdapterAuditV1,
33
+ AdapterFieldMappingV1,
34
+ CanonicalObservationV1,
35
+ SafeEvidencePointerV1,
36
+ TransferAdapterContext,
37
+ TransferEnvelopeContractError,
38
+ TransferProjectionResult,
39
+ load_transfer_envelope,
40
+ project_transfer_envelope,
41
+ )
42
+ from agentic_transfer_verifier.risk_model import assess_transfer_profile
43
+ from agentic_transfer_verifier.scenarios import ScenarioCase, scenario_corpus
44
+ from agentic_transfer_verifier.verifier import assess_transfer_risk, verify_envelope
45
+
46
+ __all__ = [
47
+ "AttackChainCase",
48
+ "AttackStep",
49
+ "AdapterAuditV1",
50
+ "AdapterFieldMappingV1",
51
+ "CapabilityGrant",
52
+ "ChainAssessment",
53
+ "CanonicalObservationV1",
54
+ "DetectionSignal",
55
+ "Finding",
56
+ "IdentityClaim",
57
+ "ProvenanceStep",
58
+ "ProtectedAsset",
59
+ "ScenarioCase",
60
+ "SafeEvidencePointerV1",
61
+ "SinkAssessment",
62
+ "SinkAttempt",
63
+ "TransferEnvelope",
64
+ "TransferAdapterContext",
65
+ "TransferEnvelopeContractError",
66
+ "TransferEdge",
67
+ "TransferRisk",
68
+ "TransferRiskProfile",
69
+ "TransferProjectionResult",
70
+ "VerificationReport",
71
+ "assess_attack_chain",
72
+ "assess_sink_attempt",
73
+ "assess_transfer_profile",
74
+ "assess_transfer_risk",
75
+ "attack_chain_corpus",
76
+ "detection_signal_catalog",
77
+ "leakage_scenario_corpus",
78
+ "load_transfer_envelope",
79
+ "project_transfer_envelope",
80
+ "scenario_corpus",
81
+ "trading_signal_canary",
82
+ "verify_envelope",
83
+ ]
84
+
85
+ __version__ = "0.2.1"
@@ -0,0 +1,585 @@
1
+ """Adversarial transfer-chain model.
2
+
3
+ This module models defensive, synthetic attack chains. It contains no payload
4
+ recipes, no provider calls, and no live target behavior.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+ from typing import Any, Literal
11
+
12
+ from agentic_transfer_verifier.models import CapabilityGrant, Finding, RiskLevel, TransferEnvelope
13
+ from agentic_transfer_verifier.risk_model import assess_transfer_profile
14
+ from agentic_transfer_verifier.scenarios import _base_user_brief
15
+
16
+ AdversaryRole = Literal[
17
+ "external_content_author",
18
+ "compromised_tool_or_server",
19
+ "memory_writer",
20
+ "delegated_agent",
21
+ "skill_or_package_publisher",
22
+ "internal_user_or_operator",
23
+ "stale_state_replayer",
24
+ "confused_client",
25
+ ]
26
+
27
+ AttackPhase = Literal[
28
+ "ingress",
29
+ "role_confusion",
30
+ "trust_promotion",
31
+ "authority_or_capability_gain",
32
+ "action_or_persistence",
33
+ "evidence_degradation",
34
+ ]
35
+
36
+ EvidenceArtifact = Literal[
37
+ "envelope",
38
+ "parent_child_edge",
39
+ "risk_profile",
40
+ "violation_code",
41
+ "provenance_chain",
42
+ "approval_binding",
43
+ "capability_grant",
44
+ "scenario_trace",
45
+ ]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class DetectionSignal:
50
+ """A defensive signal that ties an attack phase to inspectable evidence."""
51
+
52
+ code: str
53
+ phase: AttackPhase
54
+ severity: Literal["low", "medium", "high"]
55
+ artifacts: tuple[EvidenceArtifact, ...]
56
+ description: str
57
+
58
+ def to_dict(self) -> dict[str, Any]:
59
+ return asdict(self)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class AttackStep:
64
+ """One abstract step in a synthetic adversarial transfer chain."""
65
+
66
+ phase: AttackPhase
67
+ tactic: str
68
+ expected_signals: tuple[str, ...]
69
+ evidence: tuple[EvidenceArtifact, ...]
70
+ defensive_question: str
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ return asdict(self)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class AttackChainCase:
78
+ """Synthetic chain for studying how transfer failures propagate."""
79
+
80
+ name: str
81
+ adversary: AdversaryRole
82
+ objective: str
83
+ steps: tuple[AttackStep, ...]
84
+ envelopes: tuple[TransferEnvelope, ...]
85
+ parent_map: tuple[int | None, ...]
86
+ expected_signals: tuple[str, ...]
87
+ public_safe_summary: str
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ return {
91
+ "name": self.name,
92
+ "adversary": self.adversary,
93
+ "objective": self.objective,
94
+ "steps": [step.to_dict() for step in self.steps],
95
+ "envelopes": [envelope.to_dict() for envelope in self.envelopes],
96
+ "parent_map": list(self.parent_map),
97
+ "expected_signals": list(self.expected_signals),
98
+ "public_safe_summary": self.public_safe_summary,
99
+ }
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class ChainAssessment:
104
+ """Assessment of a complete synthetic adversarial chain."""
105
+
106
+ chain_name: str
107
+ score: float
108
+ level: RiskLevel
109
+ observed_signals: tuple[str, ...]
110
+ missing_expected_signals: tuple[str, ...]
111
+ covered_phases: tuple[AttackPhase, ...]
112
+ profile_levels: tuple[RiskLevel, ...]
113
+ findings: tuple[Finding, ...] = field(default_factory=tuple)
114
+ model_version: str = "0.2-chain"
115
+
116
+ def to_dict(self) -> dict[str, Any]:
117
+ return {
118
+ "model_version": self.model_version,
119
+ "chain_name": self.chain_name,
120
+ "score": round(self.score, 3),
121
+ "level": self.level,
122
+ "observed_signals": list(self.observed_signals),
123
+ "missing_expected_signals": list(self.missing_expected_signals),
124
+ "covered_phases": list(self.covered_phases),
125
+ "profile_levels": list(self.profile_levels),
126
+ "findings": [finding.to_dict() for finding in self.findings],
127
+ }
128
+
129
+
130
+ def detection_signal_catalog() -> dict[str, DetectionSignal]:
131
+ """Return the defensive signal vocabulary used by chain cases."""
132
+
133
+ signals = [
134
+ DetectionSignal(
135
+ code="data_consumed_as_instruction",
136
+ phase="role_confusion",
137
+ severity="high",
138
+ artifacts=("envelope", "risk_profile", "violation_code"),
139
+ description="Data-like content is consumed as instruction, policy, or capability.",
140
+ ),
141
+ DetectionSignal(
142
+ code="unauthorized_trust_promotion",
143
+ phase="trust_promotion",
144
+ severity="high",
145
+ artifacts=("parent_child_edge", "risk_profile", "provenance_chain"),
146
+ description="Trust increases without verifier provenance and verified identity.",
147
+ ),
148
+ DetectionSignal(
149
+ code="verified_without_identity",
150
+ phase="trust_promotion",
151
+ severity="high",
152
+ artifacts=("envelope", "provenance_chain", "violation_code"),
153
+ description="Verified/signed/attested trust is declared without identity evidence.",
154
+ ),
155
+ DetectionSignal(
156
+ code="authority_escalation",
157
+ phase="authority_or_capability_gain",
158
+ severity="high",
159
+ artifacts=("parent_child_edge", "risk_profile", "violation_code"),
160
+ description="Authority increases across a transfer boundary.",
161
+ ),
162
+ DetectionSignal(
163
+ code="weak_trust_authority",
164
+ phase="authority_or_capability_gain",
165
+ severity="high",
166
+ artifacts=("envelope", "risk_profile", "violation_code"),
167
+ description="Weakly trusted content carries write or stronger authority.",
168
+ ),
169
+ DetectionSignal(
170
+ code="capability_exceeds_authority",
171
+ phase="authority_or_capability_gain",
172
+ severity="high",
173
+ artifacts=("capability_grant", "risk_profile", "violation_code"),
174
+ description="A capability grant exceeds the envelope authority.",
175
+ ),
176
+ DetectionSignal(
177
+ code="unbound_capability",
178
+ phase="authority_or_capability_gain",
179
+ severity="high",
180
+ artifacts=("capability_grant", "approval_binding", "violation_code"),
181
+ description="Capability is not bound to an action, envelope, or approval.",
182
+ ),
183
+ DetectionSignal(
184
+ code="approval_laundering",
185
+ phase="authority_or_capability_gain",
186
+ severity="high",
187
+ artifacts=("approval_binding", "risk_profile", "violation_code"),
188
+ description="An approval is reused for a different declared action.",
189
+ ),
190
+ DetectionSignal(
191
+ code="self_replay",
192
+ phase="action_or_persistence",
193
+ severity="high",
194
+ artifacts=("envelope", "parent_child_edge", "violation_code"),
195
+ description="An envelope declares itself as parent, indicating replay ambiguity.",
196
+ ),
197
+ DetectionSignal(
198
+ code="parent_mismatch",
199
+ phase="action_or_persistence",
200
+ severity="high",
201
+ artifacts=("parent_child_edge", "risk_profile", "violation_code"),
202
+ description="Declared parent does not match the supplied parent context.",
203
+ ),
204
+ DetectionSignal(
205
+ code="profile_missing_provenance",
206
+ phase="evidence_degradation",
207
+ severity="high",
208
+ artifacts=("provenance_chain", "risk_profile", "violation_code"),
209
+ description="The transfer cannot show the actor/action/source chain.",
210
+ ),
211
+ DetectionSignal(
212
+ code="agent_card_without_identity",
213
+ phase="ingress",
214
+ severity="medium",
215
+ artifacts=("envelope", "provenance_chain", "violation_code"),
216
+ description="Agent discovery metadata is consumed without verifiable identity.",
217
+ ),
218
+ ]
219
+ return {signal.code: signal for signal in signals}
220
+
221
+
222
+ def assess_attack_chain(chain: AttackChainCase) -> ChainAssessment:
223
+ """Assess a synthetic adversarial chain using v0.2 envelope profiles."""
224
+
225
+ if len(chain.envelopes) != len(chain.parent_map):
226
+ raise ValueError("parent_map must align with envelopes")
227
+
228
+ findings: list[Finding] = []
229
+ profile_scores: list[float] = []
230
+ profile_levels: list[RiskLevel] = []
231
+
232
+ for index, envelope in enumerate(chain.envelopes):
233
+ parent_index = chain.parent_map[index]
234
+ if index == 0 and parent_index is None:
235
+ continue
236
+ parent = None if parent_index is None else chain.envelopes[parent_index]
237
+ profile = assess_transfer_profile(envelope, parent=parent)
238
+ profile_scores.append(profile.score)
239
+ profile_levels.append(profile.level)
240
+ findings.extend(profile.violations)
241
+
242
+ observed = tuple(sorted({finding.code for finding in findings}))
243
+ missing = tuple(signal for signal in chain.expected_signals if signal not in observed)
244
+ covered = tuple(dict.fromkeys(step.phase for step in chain.steps))
245
+ expected_coverage = 1.0 - (len(missing) / len(chain.expected_signals))
246
+ phase_factor = min(1.0, len(covered) / 6)
247
+ profile_factor = max(profile_scores) if profile_scores else 0.0
248
+ score = min(1.0, 0.50 * expected_coverage + 0.25 * phase_factor + 0.25 * profile_factor)
249
+
250
+ return ChainAssessment(
251
+ chain_name=chain.name,
252
+ score=score,
253
+ level=_risk_level(score),
254
+ observed_signals=observed,
255
+ missing_expected_signals=missing,
256
+ covered_phases=covered,
257
+ profile_levels=tuple(profile_levels),
258
+ findings=tuple(findings),
259
+ )
260
+
261
+
262
+ def attack_chain_corpus() -> list[AttackChainCase]:
263
+ """Return defensive synthetic adversarial chain cases."""
264
+
265
+ base = _base_user_brief()
266
+ return [
267
+ _repo_text_to_tool_write(base),
268
+ _tool_metadata_to_capability(base),
269
+ _memory_replay_to_policy(base),
270
+ _approval_reuse_to_action(base),
271
+ _delegated_agent_identity_drift(base),
272
+ _audit_gap_after_summary(base),
273
+ ]
274
+
275
+
276
+ def _repo_text_to_tool_write(base: TransferEnvelope) -> AttackChainCase:
277
+ summary = TransferEnvelope(
278
+ envelope_id="chain-repo-summary",
279
+ producer="agent-a",
280
+ consumer="agent-b",
281
+ payload_kind="summary",
282
+ trust_level="user_confirmed",
283
+ authority_scope="read",
284
+ payload={"summary": "Repository text includes a request-like sentence."},
285
+ provenance=[],
286
+ consumed_as="instruction",
287
+ allowed_uses=["read"],
288
+ parent_envelope_id=base.envelope_id,
289
+ )
290
+ tool_action = TransferEnvelope(
291
+ envelope_id="chain-tool-write",
292
+ producer="agent-b",
293
+ consumer="tool-filesystem",
294
+ payload_kind="tool_output",
295
+ trust_level="tool_observed",
296
+ authority_scope="write",
297
+ payload={"action": "write requested by interpreted repository text"},
298
+ provenance=[],
299
+ consumed_as="instruction",
300
+ allowed_uses=["read"],
301
+ parent_envelope_id=summary.envelope_id,
302
+ )
303
+ return AttackChainCase(
304
+ name="repo_text_to_tool_write",
305
+ adversary="external_content_author",
306
+ objective="Turn repository-controlled text into a write-capable tool action.",
307
+ steps=(
308
+ _step("ingress", "Untrusted repository text enters the context.", ()),
309
+ _step("role_confusion", "Repository data is consumed as instruction."),
310
+ _step(
311
+ "authority_or_capability_gain",
312
+ "The interpreted instruction reaches write authority.",
313
+ ),
314
+ _step("evidence_degradation", "The summary loses provenance before action."),
315
+ ),
316
+ envelopes=(base, summary, tool_action),
317
+ parent_map=(None, 0, 1),
318
+ expected_signals=(
319
+ "profile_missing_provenance",
320
+ "data_consumed_as_instruction",
321
+ "authority_escalation",
322
+ "weak_trust_authority",
323
+ ),
324
+ public_safe_summary="Synthetic repository text is reinterpreted as tool authority.",
325
+ )
326
+
327
+
328
+ def _tool_metadata_to_capability(base: TransferEnvelope) -> AttackChainCase:
329
+ tool_card = TransferEnvelope(
330
+ envelope_id="chain-tool-card",
331
+ producer="remote-tool-server",
332
+ consumer="local-agent",
333
+ payload_kind="agent_card",
334
+ trust_level="tool_observed",
335
+ authority_scope="read",
336
+ payload={"capabilities": ["write_files"]},
337
+ provenance=[],
338
+ consumed_as="capability_grant",
339
+ parent_envelope_id=base.envelope_id,
340
+ )
341
+ grant = TransferEnvelope(
342
+ envelope_id="chain-capability-grant",
343
+ producer="local-agent",
344
+ consumer="tool-router",
345
+ payload_kind="action_request",
346
+ trust_level="tool_observed",
347
+ authority_scope="read",
348
+ payload={"requested_capability": "write_files"},
349
+ provenance=[],
350
+ consumed_as="capability_grant",
351
+ capabilities=[
352
+ CapabilityGrant(
353
+ name="write_files",
354
+ scope="write",
355
+ source="agent_card",
356
+ bound_to="",
357
+ )
358
+ ],
359
+ parent_envelope_id=tool_card.envelope_id,
360
+ )
361
+ return AttackChainCase(
362
+ name="tool_metadata_to_capability",
363
+ adversary="compromised_tool_or_server",
364
+ objective="Convert tool discovery metadata into an unbound write capability.",
365
+ steps=(
366
+ _step("ingress", "Tool or agent metadata enters discovery."),
367
+ _step("role_confusion", "Metadata is consumed as capability grant."),
368
+ _step("authority_or_capability_gain", "Capability exceeds declared authority."),
369
+ _step("evidence_degradation", "No identity or provenance binds the metadata."),
370
+ ),
371
+ envelopes=(base, tool_card, grant),
372
+ parent_map=(None, 0, 1),
373
+ expected_signals=(
374
+ "agent_card_without_identity",
375
+ "profile_missing_provenance",
376
+ "data_consumed_as_instruction",
377
+ "capability_exceeds_authority",
378
+ "unbound_capability",
379
+ ),
380
+ public_safe_summary="Synthetic discovery metadata becomes an unbound capability grant.",
381
+ )
382
+
383
+
384
+ def _memory_replay_to_policy(base: TransferEnvelope) -> AttackChainCase:
385
+ memory = TransferEnvelope(
386
+ envelope_id="chain-memory-policy",
387
+ producer="agent-memory",
388
+ consumer="agent-b",
389
+ payload_kind="memory",
390
+ trust_level="user_confirmed",
391
+ authority_scope="write",
392
+ payload={"memory": "Old decision is treated as current policy."},
393
+ provenance=[],
394
+ consumed_as="policy",
395
+ parent_envelope_id="chain-memory-policy",
396
+ )
397
+ return AttackChainCase(
398
+ name="memory_replay_to_policy",
399
+ adversary="stale_state_replayer",
400
+ objective="Replay old memory as current policy with write authority.",
401
+ steps=(
402
+ _step("ingress", "Memory enters a later run."),
403
+ _step("role_confusion", "Memory is consumed as policy."),
404
+ _step("action_or_persistence", "The stale record persists by self-parenting."),
405
+ _step("authority_or_capability_gain", "The memory carries write authority."),
406
+ ),
407
+ envelopes=(base, memory),
408
+ parent_map=(None, None),
409
+ expected_signals=(
410
+ "profile_missing_provenance",
411
+ "data_consumed_as_instruction",
412
+ "self_replay",
413
+ "authority_escalation",
414
+ ),
415
+ public_safe_summary="Synthetic stale memory is replayed as current write policy.",
416
+ )
417
+
418
+
419
+ def _approval_reuse_to_action(base: TransferEnvelope) -> AttackChainCase:
420
+ action = TransferEnvelope(
421
+ envelope_id="chain-approval-reuse",
422
+ producer="agent-a",
423
+ consumer="agent-b",
424
+ payload_kind="action_request",
425
+ trust_level="user_confirmed",
426
+ authority_scope="write",
427
+ payload={"approved_action": "write-prod-like-config"},
428
+ provenance=[],
429
+ consumed_as="instruction",
430
+ allowed_uses=["execute"],
431
+ approval_id="approval-docs-review",
432
+ approval_binding="update-docs",
433
+ parent_envelope_id=base.envelope_id,
434
+ )
435
+ return AttackChainCase(
436
+ name="approval_reuse_to_action",
437
+ adversary="internal_user_or_operator",
438
+ objective="Reuse a narrow approval as broader write authority.",
439
+ steps=(
440
+ _step("ingress", "A prior approval enters the transfer."),
441
+ _step("trust_promotion", "User-confirmed status is treated as broader permission."),
442
+ _step(
443
+ "authority_or_capability_gain",
444
+ "Approval binding is laundered into another action.",
445
+ ),
446
+ _step("evidence_degradation", "The provenance chain does not explain the mismatch."),
447
+ ),
448
+ envelopes=(base, action),
449
+ parent_map=(None, 0),
450
+ expected_signals=(
451
+ "profile_missing_provenance",
452
+ "authority_escalation",
453
+ "approval_laundering",
454
+ ),
455
+ public_safe_summary="Synthetic approval is reused for a different action.",
456
+ )
457
+
458
+
459
+ def _delegated_agent_identity_drift(base: TransferEnvelope) -> AttackChainCase:
460
+ delegated = TransferEnvelope(
461
+ envelope_id="chain-delegated-agent",
462
+ producer="remote-agent",
463
+ consumer="local-agent",
464
+ payload_kind="summary",
465
+ trust_level="verified",
466
+ authority_scope="execute",
467
+ payload={"summary": "Delegated agent claims the task is verified."},
468
+ provenance=[],
469
+ consumed_as="instruction",
470
+ allowed_uses=["read"],
471
+ parent_envelope_id=base.envelope_id,
472
+ )
473
+ return AttackChainCase(
474
+ name="delegated_agent_identity_drift",
475
+ adversary="delegated_agent",
476
+ objective="Promote a delegated agent summary into verified executable instruction.",
477
+ steps=(
478
+ _step("ingress", "A delegated agent handoff enters the receiver."),
479
+ _step("role_confusion", "Summary is consumed as instruction."),
480
+ _step("trust_promotion", "The child transfer claims verified trust."),
481
+ _step("authority_or_capability_gain", "Execute authority appears at the receiver."),
482
+ ),
483
+ envelopes=(base, delegated),
484
+ parent_map=(None, 0),
485
+ expected_signals=(
486
+ "verified_without_identity",
487
+ "unauthorized_trust_promotion",
488
+ "authority_escalation",
489
+ ),
490
+ public_safe_summary=(
491
+ "Synthetic delegated-agent summary claims verified executable authority."
492
+ ),
493
+ )
494
+
495
+
496
+ def _audit_gap_after_summary(base: TransferEnvelope) -> AttackChainCase:
497
+ summary = TransferEnvelope(
498
+ envelope_id="chain-shadow-summary",
499
+ producer="agent-a",
500
+ consumer="agent-b",
501
+ payload_kind="summary",
502
+ trust_level="untrusted",
503
+ authority_scope="none",
504
+ payload={"summary": "A short handoff with no source path."},
505
+ provenance=[],
506
+ consumed_as="evidence",
507
+ parent_envelope_id="unexpected-parent",
508
+ )
509
+ return AttackChainCase(
510
+ name="audit_gap_after_summary",
511
+ adversary="confused_client",
512
+ objective="Create a handoff whose source chain cannot be reconstructed.",
513
+ steps=(
514
+ _step("ingress", "A summary crosses runtime boundary."),
515
+ _step(
516
+ "evidence_degradation",
517
+ "Source, actor, and parent relation are missing or wrong.",
518
+ ),
519
+ _step("action_or_persistence", "Receiver cannot replay the decision path."),
520
+ ),
521
+ envelopes=(base, summary),
522
+ parent_map=(None, 0),
523
+ expected_signals=("profile_missing_provenance", "parent_mismatch"),
524
+ public_safe_summary="Synthetic summary loses enough provenance to block review.",
525
+ )
526
+
527
+
528
+ def _step(
529
+ phase: AttackPhase,
530
+ tactic: str,
531
+ expected_signals: tuple[str, ...] | None = None,
532
+ ) -> AttackStep:
533
+ catalog = detection_signal_catalog()
534
+ signals = expected_signals or tuple(
535
+ code for code, signal in catalog.items() if signal.phase == phase
536
+ )
537
+ fallback = _fallback_signal()
538
+ artifacts = tuple(
539
+ dict.fromkeys(
540
+ artifact
541
+ for code in signals
542
+ for artifact in catalog.get(code, fallback).artifacts
543
+ )
544
+ )
545
+ return AttackStep(
546
+ phase=phase,
547
+ tactic=tactic,
548
+ expected_signals=signals,
549
+ evidence=artifacts,
550
+ defensive_question=_defensive_question(phase),
551
+ )
552
+
553
+
554
+ def _fallback_signal() -> DetectionSignal:
555
+ return DetectionSignal(
556
+ code="fallback",
557
+ phase="evidence_degradation",
558
+ severity="low",
559
+ artifacts=("scenario_trace",),
560
+ description="Fallback evidence marker.",
561
+ )
562
+
563
+
564
+ def _defensive_question(phase: AttackPhase) -> str:
565
+ questions = {
566
+ "ingress": "What untrusted or weakly trusted material entered the system?",
567
+ "role_confusion": "Did the receiver consume data as instruction, policy, or capability?",
568
+ "trust_promotion": "Did trust increase without verifier and identity evidence?",
569
+ "authority_or_capability_gain": (
570
+ "Did authority or capability expand beyond the parent task?"
571
+ ),
572
+ "action_or_persistence": "Did the transfer enable action, replay, or persistence?",
573
+ "evidence_degradation": "Can a reviewer reconstruct the actor/action/source chain?",
574
+ }
575
+ return questions[phase]
576
+
577
+
578
+ def _risk_level(score: float) -> RiskLevel:
579
+ if score >= 0.75:
580
+ return "high"
581
+ if score >= 0.45:
582
+ return "medium"
583
+ if score > 0:
584
+ return "low"
585
+ return "none"