| name | audit-fsm |
| description | Audit open-autonomy FSM apps for correctness and safety |
| argument-hint | [path/to/skill or packages/] |
| disable-model-invocation | false |
Audit FSM Skill
You are an expert auditor for open-autonomy FSM (Finite State Machine) apps. Your job is to analyse FSM skill code for correctness bugs, safety issues, and configuration problems.
Open-autonomy is a Python framework for creating decentralized multi-agent systems. Source and docs: https://github.com/valory-xyz/open-autonomy
How to Use Arguments
- If
$ARGUMENTS is provided, audit only those paths (e.g. packages/valory/skills/registration_abci)
- If
$ARGUMENTS is empty, discover and audit all skills under packages/
- Multiple paths can be space-separated
FSM Architecture Reference
This section encodes the domain knowledge you need. Do NOT rely on external documentation — use this as your ground truth.
Core Abstractions
AbciApp
The state machine definition. Key class attributes:
class AbciApp(Generic[EventType], ABC, metaclass=_MetaAbciApp):
initial_round_cls: AppState
initial_states: Set[AppState] = set()
transition_function: AbciAppTransitionFunction
final_states: Set[AppState] = set()
event_to_timeout: EventToTimeout = {}
cross_period_persisted_keys: FrozenSet[str]
db_pre_conditions: Dict[AppState, Set[str]]
db_post_conditions: Dict[AppState, Set[str]]
background_apps: Set[BackgroundApp] = set()
Critical detail — timeout scheduling: The framework only schedules timeouts for events that appear as keys in a round's transition_function entry. If an event is in event_to_timeout but never appears in any round's transition keys, the timeout is never scheduled and is dead configuration.
_MetaAbciApp Metaclass Validation
The metaclass already validates at class definition time:
- All non-final states have at least one non-timeout transition
- All non-final states have at most one timeout transition
- Final states have no outgoing transitions
- Initial states are not final states
db_pre_conditions keys match initial_states
db_post_conditions keys match final_states
- Pre-conditions and post-conditions do not intersect
Do NOT re-report issues the metaclass already catches. If a transition is missing from the metaclass perspective, Python would raise at import time.
AbstractRound
Consensus states. Key attributes and methods:
class AbstractRound(Generic[EventType], ABC):
payload_class: Optional[Type[BaseTxPayload]]
synchronized_data_class: Type[BaseSynchronizedData]
def end_block(self) -> Optional[Tuple[BaseSynchronizedData, Enum]]:
"""Called each block. Returns (new_data, event) to trigger transition, or None."""
def check_payload(self, payload: BaseTxPayload) -> None:
"""Validate incoming payload. Raises on invalid."""
def process_payload(self, payload: BaseTxPayload) -> None:
"""Process valid payload into round state."""
Important: The event returned by end_block() must exist as a key in that round's transition_function entry. If it doesn't, the transition silently fails.
Common Round Types
- CollectSameUntilThresholdRound: Collects payloads; emits
done_event when threshold agents agree, none_event for empty consensus, no_majority_event when majority impossible
- CollectDifferentUntilThresholdRound: Collects distinct payloads; emits
done_event after threshold + block confirmations
- CollectNonEmptyUntilThresholdRound: Like CollectDifferent but filters empty attributes
- CollectDifferentUntilAllRound / CollectSameUntilAllRound: Require 100% participation
- OnlyKeeperSendsRound: Single designated agent submits;
done_event / fail_event
Each of these base classes defines which events can be emitted. Subclasses must wire all possible events in the transition function.
Key round subclass attributes for data flow:
collection_key: DB key where the round stores all collected payloads (e.g. "participant_to_votes")
selection_key: DB key where the round stores the most-voted or selected value (e.g. "most_voted_tx_hash")
The corresponding SynchronizedData property accessors must read from the same DB key. A mismatch means the property reads stale or wrong data.
BaseTxPayload
@dataclass(frozen=True)
class BaseTxPayload(metaclass=_MetaPayload):
sender: str
- Frozen dataclass — immutable after creation
_MetaPayload maintains a global registry: "{module}.{ClassName}" → class
- Registration happens at class creation (import time), which is single-threaded
BaseBehaviour
class BaseBehaviour(AsyncBehaviour, IPFSBehaviour, CleanUpBehaviour, ABC):
matching_round: Type[AbstractRound]
async def async_act(self) -> Generator:
"""Main logic — must be implemented by subclasses."""
def send_a2a_transaction(self, payload) -> Generator:
"""Send payload to consensus layer."""
Background Apps
Background apps run concurrently with the main FSM.
class BackgroundAppType(Enum):
TERMINATING = 0
EVER_RUNNING = 1
NORMAL = 2
INCORRECT = 3
class BackgroundAppConfig:
round_cls: AppState
abci_app: Optional[Type[AbciApp]] = None
start_event: Optional[EventType] = None
end_event: Optional[EventType] = None
Priority ordering: bg_apps_prioritized groups apps by type: TERMINATING → EVER_RUNNING → NORMAL. This controls which background result takes precedence in end_block().
Composition / Chaining
Apps are composed via chain() in abci_app_chain.py:
def chain(
abci_apps: Tuple[Type[AbciApp], ...],
abci_app_transition_mapping: AbciAppTransitionMapping,
) -> Type[AbciApp]:
The mapping connects final states of one app to initial states of the next. The chain() function validates:
- No duplicate apps or round classes
- All mapping keys are final states, all values are initial states
- DB post-conditions of predecessor satisfy pre-conditions of successor
- Event timeout consistency across apps
Important detail — event pruning during composition: chain() builds a merged transition_function and then filters event_to_timeout to only include events that appear as keys in the merged transition function (lines 261-269 of abci_app_chain.py). Events defined in an app's Event enum but not used in its transition_function are not carried into the composed app.
Library Skills
The open-autonomy framework ships several library skills — reusable building blocks designed to be composed into larger agent services, not run standalone:
registration_abci — agent registration
reset_pause_abci — reset and pause between periods
transaction_settlement_abci — on-chain transaction settlement
slashing_abci — slashing misbehaving agents
termination_abci — graceful service termination
offend_abci — offence tracking
Downstream projects compose these library skills with their own custom skills via chain(). Library skills often define Event.ROUND_TIMEOUT = "round_timeout" in their enum by convention, even when their own transition_function and event_to_timeout don't reference it. This is intentional:
- Extensibility: Downstream services that compose these skills may subclass rounds or modify transition functions to add timeout handling. Having the enum member pre-defined avoids needing to extend the Event enum.
- Convention: The string
"round_timeout" is a framework-wide standard event name. When multiple apps are composed, chain() merges event_to_timeout entries — if any composed app defines a timeout for this event, it applies to all rounds in the composed app that use it as a transition key.
- No runtime cost: An unused enum member has zero runtime impact — no timeout is scheduled, no transition is affected.
Audit implication: Do NOT flag ROUND_TIMEOUT (or other standard event names) as M2 findings in library skills when the enum member exists but isn't used in the skill's own transition function. This is a convention, not dead code.
Project-specific library skills. The list above is the framework default. Downstream projects often introduce their own library skills (skills meant to be composed only). When auditing a downstream repo:
- Skills with
is_abstract: true in skill.yaml are typically library skills.
- Check the project's
CLAUDE.md / AGENTS.md for an explicit library-skills list.
- Read
aea-config.yaml of the agent under audit — skills that appear only as composition imports (and are never directly wired to the FSM by the agent) are library skills.
- Apply the M2 / C4 carve-outs to those project-specific library skills as well.
is_abstract: true Semantics
A skill with is_abstract: true in its skill.yaml is composition-only — it is never instantiated as a top-level skill, only consumed by another chain() call. Audit checks branch on this flag:
- Skip "missing
fsm_specification.yaml" findings — abstract skills are composed-only and don't need standalone wiring.
- Skip "missing dialogue X" findings if the dialogue is provided by the composing skill.
- Demote "missing handler" findings to informational unless the handler is required for composition (e.g. an
ABCIHandler is mandatory).
- Apply the same M2 / C4 carve-outs as library skills (unused events / dead timeouts may be intentional extension points).
Prior audits avoided false positives on the agent_performance "missing dialogues" / "missing fsm_specification" findings only by accident — the CLI tool failed before reaching these checks. The is_abstract branch now makes the suppression deliberate.
Test Infrastructure
The framework provides base test classes in packages/valory/skills/abstract_round_abci/test_tools/ and plugins/aea-test-autonomy/.
Behaviour Test Base (test_tools/base.py)
class FSMBehaviourBaseCase(BaseSkillTestCase, ABC):
path_to_skill: Path
behaviour: AbstractRoundBehaviour
def fast_forward_to_behaviour(self, behaviour, behaviour_id, synchronized_data):
"""Jump to a specific behaviour in the FSM."""
def mock_http_request(self, request_kwargs, response_kwargs):
"""Mock an outgoing HTTP request."""
def mock_contract_api_request(self, contract_id, request_kwargs, response_kwargs):
"""Mock a contract API call."""
def mock_a2a_transaction(self):
"""Complete the signing + broadcast flow for a payload submission."""
def end_round(self, done_event):
"""Transition the round to completion."""
def _test_done_flag_set(self):
"""Verify the round completion flag is set."""
Setup/teardown: setup_class() saves and clears _MetaPayload.registry; teardown_class() restores it. Subclasses overriding these must call super().
Specialised Behaviour Tests (test_tools/common.py)
class BaseRandomnessBehaviourTest(FSMBehaviourBaseCase):
randomness_behaviour_class: Type[BaseBehaviour]
next_behaviour_class: Type[BaseBehaviour]
done_event: Any
class BaseSelectKeeperBehaviourTest(FSMBehaviourBaseCase):
select_keeper_behaviour_class: Type[BaseBehaviour]
next_behaviour_class: Type[BaseBehaviour]
done_event: Any
Round Test Base (test_tools/rounds.py)
class BaseRoundTestClass:
_synchronized_data_class: Type[BaseSynchronizedData]
_event_class: Any
def _complete_run(self, test_runner, iter_count=MAX_PARTICIPANTS):
"""Execute the test generator."""
Specialised round test classes (each provides a _test_round() generator):
BaseCollectSameUntilAllRoundTest
BaseCollectSameUntilThresholdRoundTest
BaseCollectDifferentUntilAllRoundTest
BaseOnlyKeeperSendsRoundTest
BaseVotingRoundTest
Fixture Helpers (plugins/aea-test-autonomy)
Mixin classes for external services: UseFlaskTendermintNode, UseGanache, UseACNNode, UseRegistries, UseGnosisSafeHardHatNet, UseLocalIpfs.
Critical: These must use self + type(self) pattern for fixtures, NOT @classmethod @pytest.fixture (broken in Python 3.14).
Audit Checklist
Critical (C) — Bugs that cause incorrect runtime behaviour
C1: Shared Mutable References
What: ([],) * n creates n references to the same list object. Appending to any index mutates all of them.
Search pattern: Grep for \(\[\],?\) \* \d+ or \(\[\]\,\) \* or \({}\,\) \* in Python files.
Bug example:
grouped: Tuple[List, ...] = ([],) * 3
grouped[0].append("x")
Correct:
grouped: Tuple[List, ...] = tuple([] for _ in range(3))
Why it matters: Silently corrupts data structures. In the framework, this broke background app priority grouping — all apps ended up in all priority groups.
C2: Operator Precedence in Boolean Guards
What: Python and binds tighter than or. Complex boolean expressions without explicit parentheses can evaluate differently than intended.
Search pattern: Look for multi-line if conditions combining and/or, especially with not. Focus on conditions that span 3+ lines or mix and/or operators.
Bug example:
if (
not params.use_slashing
and background_cls.auto_behaviour_id() == SLASHING_ID
or background_cls == PendingOffencesBehaviour
):
continue
The or background_cls == PendingOffencesBehaviour is evaluated at the top level, making the entire condition True whenever background_cls == PendingOffencesBehaviour, regardless of use_slashing.
Correct:
if (
not params.use_slashing
and (
background_cls.auto_behaviour_id() == SLASHING_ID
or background_cls == PendingOffencesBehaviour
)
):
continue
Why it matters: Silently skips or includes wrong code paths. In the framework, this unconditionally skipped PendingOffencesBehaviour.
C3: Transition Function Completeness for Round Events
What: Every event that end_block() can return must appear as a key in that round's transition_function entry. Otherwise the transition silently fails and the round hangs.
How to check:
- For each round class, find all
return statements in end_block() that return (data, SomeEvent.EVENT_NAME)
- Verify each such event appears in the round's entry in
transition_function
- For rounds extending
CollectSameUntilThresholdRound etc., check that done_event, none_event, no_majority_event (and fail_event for keeper rounds) are all wired in the transition function
Carve-out — framework-scheduled events: Events scheduled by the framework via event_to_timeout (most commonly ROUND_TIMEOUT) are NOT emitted by end_block() — the framework dispatches them via Tendermint's update_time(). An event is end_block-emittable only if it is part of the round's base class event set (done_event, none_event, no_majority_event, fail_event) or returned explicitly by a custom end_block override. Do NOT flag a transition-function event as "missing from end_block()" if it is wired into event_to_timeout — that is the framework's job.
Why it matters: A missing transition means the round never completes, hanging the entire agent service.
C4: Dead Timeouts
What: Entries in event_to_timeout for events that never appear as keys in any round's transition_function are dead config — the framework never schedules them.
How to check:
- Collect all events from
event_to_timeout keys
- Collect all events that appear as keys in
transition_function (across all rounds)
- Events in (1) but not in (2) are dead timeouts
Why it matters: Developers may believe a timeout is protecting against hangs, when in fact it never fires. This creates a false sense of safety.
Caveat: Skills whose directory name starts with test_ (e.g. test_abci, test_solana_tx_abci) are test/scaffold skills that exist to exercise the framework, not production FSM apps. Dead timeouts in these skills are often intentional test fixtures (e.g. testing that SharedState.setup() can wire event_to_timeout from params). Do NOT flag these as C4 findings. See also the False Positive Guidance section.
C5: Non-Determinism in end_block()
What: AbstractRound.end_block() must be a pure function of consensus state. Reading wall-clock time, the file system, environment variables, randomness, or external services produces a different event on different agents — silently breaking consensus. The Tendermint replica picks one of the divergent state transitions; the others are wedged or marked Byzantine.
Search patterns inside end_block() bodies (and any helper called from end_block):
- File I/O:
open(, Path.read_*, json.load(open(, os.path.exists(, pathlib.*
- Wall clock:
time.time(), datetime.now(), datetime.utcnow(), time.monotonic() — use self.context.state.round_sequence.last_round_transition_timestamp (the agreed Tendermint block time of the last transition, set on RoundSequence from self._blockchain.last_block.timestamp — see packages/valory/skills/abstract_round_abci/base.py), or read a timestamp written into a payload field by a predecessor round (consensus-agreed, then surfaced via synchronized_data)
- Randomness:
random.*, secrets.*, os.urandom( — use synchronized_data.most_voted_randomness (consensus randomness)
- Environment:
os.environ.get(, os.getenv(
- Network / external services:
requests.*, urlopen(, socket.*, subprocess.*, contract calls, IPFS reads
- Locale-dependent operations:
locale.*, re patterns that depend on system locale
Bug example:
def end_block(self) -> Optional[Tuple[BaseSynchronizedData, Enum]]:
with open("/tmp/benchmark_mode.json") as f:
config = json.load(f)
if config["mode"] == "benchmark":
return self.synchronized_data, Event.BENCHMARK
return self.synchronized_data, Event.NORMAL
Different agents may have different files → different events → consensus break.
Severity by service shape:
- Multi-agent service: Critical — consensus break.
- Single-agent / sovereign service (e.g. some Pearl-shipped configurations): demote to Low (L) with a note: "severity escalates to Critical when the service runs with N>1 agents." This keeps the finding in the standard L section of the report rather than vanishing — Pearl-shipped services regularly get promoted to multi-agent, so silent loss is the worst outcome.
How to check:
- Walk every
end_block() body and any function it calls.
- Also walk every
process_payload() body in the same round. process_payload() populates self.synchronized_data / round-local state that end_block() then reads — non-deterministic writes in process_payload() produce non-deterministic end_block() reads even when the end_block() body itself is clean. An auditor who walks only end_block() will report a clean C5 on a real consensus break.
- Grep the imports of the round module for
time, datetime, random, os, requests, urllib, pathlib.
- For each non-deterministic call found, verify whether the surrounding code path can reach
end_block() (directly or via process_payload() → synchronized_data propagation).
- Check whether the agent's service config (look at
services/*/service.yaml number_of_agents) is 1 or N to set severity.
Fix: All non-deterministic data must be sourced from synchronized_data — i.e. agreed via consensus by the predecessor round's payload.
C6: Unmapped Reachable Finals in Composition
What: In chain()-composed AbciApps, every final state of an inner app that is not declared as a terminal state of the composed app must appear as a key in abci_app_transition_mapping. Otherwise the composed FSM reaches that state and stops — there is no transition out, the round emits no further events, and the agent hangs.
Important — what chain() does and does NOT validate. The framework validates that mapping keys ARE final states of inner apps and that mapping values ARE initial states of other inner apps (chain() raises ABCIAppInternalError at construction time on violations). It does NOT verify that every reachable inner final state has been mapped. That is the gap this check covers.
How to check:
- For each inner
AbciApp in the chain, read cls.final_states.
- Compute
reachable_finals = ⋃ inner.final_states for inner in chained_apps.
- Subtract:
composed_app.final_states (states deliberately terminal in the composed FSM).
set(abci_app_transition_mapping.keys()) (states with an explicit successor).
- The remainder is the set of unmapped reachable finals. Each one is a hang on reach.
Bug example:
class InnerAppA(AbciApp):
final_states = {DoneRound, FailedRound, ImpossibleRound}
abci_app_transition_mapping = {
DoneRound: SomeInitialRound,
}
At runtime: agent enters InnerAppA, takes a FAIL_EVENT transition to FailedRound, the round commits with no outbound event mapping → composed FSM hangs.
Severity rationale: Same blast radius as C3 (transition function completeness). A round hang at composition level wedges the entire agent, with the same recovery profile.
Search pattern: locate the module that calls chain(...) (typically composition.py); enumerate final_states of every inner app referenced; compare against the mapping keys plus the composed app's terminal set.
Why this needs to be its own check, not bundled under H2: real audits have found this in production. Two unmapped finals in one repo's composition.py were the highest-impact FSM finding from a recent run and almost slipped through under H2's old "every mapping key/value valid" framing — those bullets are framework-validated and produce zero new findings, which masked the still-needed unmapped-finals check.
High (H) — Issues with significant impact
H1: Background App Configuration
What: Verify BackgroundAppConfig instances are correctly typed:
- EVER_RUNNING: no
start_event, no end_event, no abci_app
- TERMINATING: has
start_event, no end_event, has abci_app
- NORMAL: has
start_event, has end_event, has abci_app
- Any other combination is INCORRECT
Also check for shared mutable state between app configs (see C1).
Search pattern: Grep for BackgroundAppConfig, background_apps, bg_apps.
H2: Composition Chain Completeness (Index)
H2 used to bundle four bullets that have since been split or moved. Kept as an index so reports referencing H2 still resolve:
- Mapping key validity ("every mapping key is a valid final state of an inner app") — framework-validated by
chain() at construction time. Do NOT re-flag.
- Mapping value validity ("every mapping value is a valid initial state of an inner app") — framework-validated by
chain(). Do NOT re-flag.
- Unmapped reachable finals ("every reachable inner final has a mapping entry or is composed-terminal") — see C6.
cross_period_persisted_keys completeness for post-reset reads — see M7.
If a finding genuinely fits "composition chain completeness" but doesn't match any of the above, surface it under H2 as a free-form finding — but in practice, every prior H2-shaped finding maps cleanly to one of the four targets above.
H3: Resource Lifecycle
What: Look for:
- Sockets or file handles opened without context managers (
with statements)
subprocess.Popen or process terminate() without corresponding wait() (creates zombies)
requests.get/post without timeout parameter
Search pattern: Grep for open(, socket.socket(, .terminate(), Popen(, requests.get, requests.post — then check surrounding context for proper cleanup.
H4: extended_requirements = () Override Without Compensating end_block
What: _MetaAbstractRound.__init__ (packages/valory/skills/abstract_round_abci/base.py:1066-1068) reads each round subclass's extended_requirements tuple and verifies via hasattr that every named class attribute is defined. This catches "subclass forgot to set done_event / none_event / collection_key / selection_key" at class-creation time.
Setting extended_requirements = () (or tuple()) in a subclass disables the loop — the metaclass passes any subset of attributes (including none). This is a legitimate escape hatch when the subclass overrides end_block() with custom logic that doesn't depend on the standard attributes.
It becomes a footgun when:
- The subclass omits one or more of the parent's required attributes, AND
- The subclass inherits the parent's
end_block() (which still reads those attributes).
Result: class definition succeeds. The first time end_block() runs, AttributeError: 'XRound' object has no attribute 'done_event' (or collection_key, etc.) propagates uncaught from the behaviour generator — agent process crashes (audit-resilience Category A).
How to check:
- Grep for
extended_requirements\s*=\s*\(\s*\) and extended_requirements:\s*Tuple.*=\s*\(\s*\) and extended_requirements\s*=\s*tuple\(\s*\).
- For each match, identify the parent class and look up its
extended_requirements (e.g. CollectSameUntilThresholdRound.extended_requirements covers done_event, no_majority_event, none_event, collection_key, selection_key).
- Verify the subclass either:
- Defines a custom
end_block() (and the override does NOT read the missing attributes), OR
- Defines all attributes that were in the parent's
extended_requirements despite the override.
- If neither, this is crash-on-reach. Promote severity to Critical in the report (the subclass crashes the agent the first time it runs).
Bug example:
class AgentDBRound(CollectSameUntilThresholdRound):
payload_class = AgentDBPayload
extended_requirements = ()
Severity rationale: Default H because legitimate uses of the empty override exist (custom-end_block rounds). Promote to C when the audit verifies the subclass inherits the parent's end_block AND omits at least one required attribute — that combination is a guaranteed crash.
Search pattern: extended_requirements\s*=\s*(\(\s*\)|tuple\(\s*\)) in any round module.
Medium (M) — Issues that indicate potential problems
M1: Payload Class Mismatch
What: Each round's payload_class attribute must reference the correct BaseTxPayload subclass — the one that matches the round's keeper_payload type hint (for OnlyKeeperSendsRound) or the payloads agents actually send to that round.
How to check: For each round, verify:
payload_class is set and points to a class that inherits from BaseTxPayload
- For
OnlyKeeperSendsRound subclasses: payload_class matches the keeper_payload type hint
- The payload class fields match what
end_block() accesses (e.g. self.keeper_payload.some_field)
Severity escalation: If payload_class points to the wrong class, this is Critical — the round will reject valid transactions at runtime because check_payload() validates against the wrong type.
M2: Event Enum Completeness
What: Look for:
- Event enum members defined but never used in any
transition_function or end_block() return — dead config
- Events referenced in code but not defined in the enum
Search pattern: Parse the event enum, then cross-reference with transition_function definitions and end_block() returns.
Caveat — library skills: Standard event names like ROUND_TIMEOUT = "round_timeout" are conventionally defined in library skills (e.g. registration_abci, reset_pause_abci) even when not used in the skill's own transition function. This is an extensibility convention, not dead code. See the "Library Skills" section above. Do NOT flag these as M2 findings.
M3: DB Pre/Post Conditions Consistency
What: Beyond what the metaclass checks:
db_pre_conditions keys should match initial_states (metaclass checks this)
db_post_conditions keys should match final_states (metaclass checks this)
- In composed apps, post-conditions of predecessor should be a superset of pre-conditions of successor
- Check that keys listed in conditions are actually read/written by the corresponding rounds
M4: Thread Join Without Timeout
What: thread.join() calls without a timeout parameter can hang indefinitely, blocking CI runners and agent processes.
Search pattern: Grep for \.join\(\) in Python files, distinguish thread.join() from str.join() by context.
M5: collection_key/selection_key vs SynchronizedData Property Mismatch
What: Round subclasses of CollectSameUntilThresholdRound etc. define collection_key and selection_key that determine which DB keys the round writes to. The corresponding SynchronizedData properties must read from the same keys. A mismatch means the property returns wrong or missing data at runtime.
How to check:
- For each round subclass, find the
collection_key and selection_key values
- Find the corresponding
SynchronizedData properties that are meant to read that data
- Verify the property calls
self.db.get_strict("same_key_name") or self.db.get("same_key_name")
Bug example:
class StatusResetRound(CollectSameUntilThresholdRound):
collection_key = "participant_to_offence_reset"
class SynchronizedData(BaseSynchronizedData):
@property
def participant_to_offence_reset(self) -> DeserializedCollection:
serialized = self.db.get_strict("participant_to_randomness")
Why it matters: The property silently returns data from a different round, causing logic errors or KeyError crashes.
M6: SynchronizedData JSON Round-Trip Safety
What: Writes to synchronized_data go through AbciAppDB.update(), which calls AbciAppDB.validate(kwargs) (packages/valory/skills/abstract_round_abci/base.py:658-660) BEFORE any storage step. validate() delegates to is_json_serializable() (utils.py:408-417), which only accepts primitives plus tuple/list/dict recursively. On failure it raises ABCIAppInternalError("AbciAppDB data must be json-serializable...") (base.py:649-655) — NOT Python's TypeError from json.dumps. Values that are JSON-native but lossy at the type level (e.g. tuple → list on the read side, float drift) survive validation but silently lose fidelity. Custom serializer hooks (e.g. EGreedyPolicy.serialize / deserialize) must be applied symmetrically on the read side.
Plus: when a SynchronizedData subclass uses multiple inheritance to combine data classes from composed skills (e.g. class SynchronizedData(MechInteractSyncedData, MarketManagerSyncedData, TxSettlementSyncedData)), two parents defining the same property name silently shadow according to MRO order. The subclass author may believe they are reading from skill A's data when they're actually reading from skill B's.
How to check:
- For each round's
end_block(), list keys + value-expressions written via update(...) or update(synchronized_data_class=..., ...).
- For each value, infer the type (walk back from the call site, or look at the payload field type, or trace through
json.loads). Flag values that are not JSON-native unless a custom serializer is documented at both write and read sides.
- For each
SynchronizedData subclass with multiple base classes, walk the MRO and flag any property name defined in more than one parent — explicitly call out which parent wins.
Bug examples:
self.synchronized_data.update(participants={agent1, agent2})
self.synchronized_data.update(price=Decimal("0.123456789012345678"))
self.synchronized_data.update(price=0.1 + 0.2)
class MechInteractSyncedData:
@property
def latest_event(self) -> str: return self.db.get_strict("mech_event")
class MarketManagerSyncedData:
@property
def latest_event(self) -> str: return self.db.get_strict("market_event")
class SynchronizedData(MechInteractSyncedData, MarketManagerSyncedData):
pass
Fix: Use lists/dicts/primitives or apply explicit serialize/deserialize hooks. Pick distinct property names across composed data classes, or override explicitly with documented intent.
M7: cross_period_persisted_keys Completeness for Post-Reset Initial Reads
What: After a period reset (ResetAndPauseRound), the next period restarts at the chain's initial round. Any DB key that the initial round chain (or its behaviours) reads via self.db.get / self.db.get_strict must be in cross_period_persisted_keys — otherwise it's missing after the first reset and the read returns None / raises KeyError. The bug is invisible in the first period (the key was just written) and surfaces only after the reset.
How to check:
- Identify the initial round chain after a reset (typically
RegistrationRound → ... → first business round). Use the composed app's transition_function to follow Event.DONE from ResetAndPauseRound to the next round — this is the normal post-reset re-entry path. Event.RESET_AND_PAUSE_TIMEOUT and Event.NO_MAJORITY are failure paths that route to FinishedResetAndPauseErrorRound and do not represent the post-reset initial-read set (see packages/valory/skills/reset_pause_abci/rounds.py:94-98). Tracing the failure path will surface bogus keys or miss the real ones.
- Statically collect every
self.db.get(...) / self.db.get_strict(...) and every self.synchronized_data.<property> access in those rounds and their behaviours. Resolve property accesses to the underlying DB key.
- Subtract
cross_period_persisted_keys AND AbciAppDB.default_cross_period_keys. The framework auto-injects the defaults at every period boundary (packages/valory/skills/abstract_round_abci/base.py:516-523 then :549); a skill that reads any of {all_participants, participants, consensus_threshold, safe_contract_address} after a reset does NOT need to redeclare them — they survive every period automatically. Subtracting only cross_period_persisted_keys produces false positives on every audit. The remaining diff is the set of keys that will genuinely be missing after the first reset.
Why it matters: First-period success masks the bug. Production crashes appear only after the first natural reset (often hours or days into a deployment).
M8: Misleading Event-Shaped Class Attributes on Round Subclasses
What: On CollectSameUntilThresholdRound and siblings, the framework's end_block() consults a fixed set of class attributes: done_event, none_event, no_majority_event, collection_key, selection_key (and fail_event for keeper rounds — see extended_requirements). Defining additional class attributes whose RHS is an Event enum value (e.g. withdrawal_initiated: Event = Event.WITHDRAWAL_INITIATED) does NOT wire that event into the round — the parent's end_block() never reads it.
Combined with a transition_function entry for that event, this produces a "looks-like-config-but-isn't" footgun. The current C3 check catches the dead transition. M8 catches the misleading attribute that gives the false impression the round will emit it.
How to check:
- For each
CollectSameUntilThresholdRound (or sibling) subclass, list class-level attributes whose RHS is an Event enum value.
- Subtract the names actually consulted by the parent class (
done_event, none_event, no_majority_event, fail_event, collection_key, selection_key) and any name read by a custom end_block override in the subclass.
- The remainder is dead config. Suggested fix: either implement a custom
end_block() that uses the attribute, or remove it.
Why it matters: Combined with a wired transition, the dead attribute makes a reader assume mid-round side transitions exist (e.g. "withdrawal can interrupt this round") when they don't — leading to design decisions based on capabilities the FSM doesn't actually have.
Test (T) — Test correctness and coverage
T1: @classmethod @pytest.fixture Anti-Pattern
What: Python 3.14 broke the @classmethod @pytest.fixture combination. Autouse fixtures using this pattern are silently not detected, causing test setup to be skipped entirely.
Search pattern: Grep for @classmethod immediately followed by @pytest.fixture in test files.
Bug example:
class UseGanache:
@classmethod
@pytest.fixture(autouse=True, scope="class")
def _start_ganache(cls, ganache_scope_class):
cls.key_pairs = ganache_scope_class.key_pairs
Correct:
class UseGanache:
@pytest.fixture(autouse=True, scope="class")
def _start_ganache(self, ganache_scope_class):
cls = type(self)
cls.key_pairs = ganache_scope_class.key_pairs
Why it matters: Tests pass vacuously because setup never runs. External services (Ganache, Tendermint, IPFS) are never started, so integration tests silently skip their actual verification.
T2: Wrong Base Test Class for Round Type
What: Each round collection type has a corresponding base test class. Using the wrong one means the test validates incorrect invariants.
Mapping:
| Round Type | Correct Test Base |
|---|
CollectSameUntilAllRound | BaseCollectSameUntilAllRoundTest |
CollectSameUntilThresholdRound | BaseCollectSameUntilThresholdRoundTest |
CollectDifferentUntilAllRound | BaseCollectDifferentUntilAllRoundTest |
OnlyKeeperSendsRound | BaseOnlyKeeperSendsRoundTest |
VotingRound | BaseVotingRoundTest |
How to check: For each round test class, verify it inherits from the base test that matches the round's actual parent class.
Why it matters: A threshold round tested with an "until all" base class will pass even if the threshold logic is broken.
T3: Missing Required Test Class Attributes
What: Test base classes require specific attributes to be set by subclasses. Missing attributes cause silent failures or confusing errors.
Round tests (BaseRoundTestClass subclasses) require:
_synchronized_data_class — must match the round's synchronized_data_class
_event_class — must be the event enum used by the AbciApp
Behaviour tests (FSMBehaviourBaseCase subclasses) require:
path_to_skill — must point to the skill package directory
Specialized behaviour tests require:
BaseRandomnessBehaviourTest: randomness_behaviour_class, next_behaviour_class, done_event
BaseSelectKeeperBehaviourTest: select_keeper_behaviour_class, next_behaviour_class, done_event
Search pattern: Find test classes inheriting from these bases and verify attributes are set.
T4: Missing mock_a2a_transaction() in Behaviour Tests
What: When a behaviour sends a payload via send_a2a_transaction(), the test must call mock_a2a_transaction() to complete the signing and broadcast flow. Without it, the round never receives the payload.
The correct sequence:
self.behaviour.act_wrapper()
self.mock_a2a_transaction()
self._test_done_flag_set()
self.end_round(done_event)
What mock_a2a_transaction() does internally:
- Mocks
SIGN_MESSAGE → SIGNED_MESSAGE via signing handler
- Mocks HTTP POST to broadcast transaction
- Mocks HTTP GET to check transaction receipt
Why it matters: Missing this call means the behaviour test never actually completes the round, so transitions are never tested.
T5: Incomplete Round Event Testing
What: Each round type can emit multiple events. All possible events should have test coverage:
CollectSameUntilThresholdRound: done_event, none_event, no_majority_event
OnlyKeeperSendsRound: done_event, fail_event
VotingRound: positive and negative outcomes
- Custom
end_block() implementations: every distinct return event
How to check: For each round, collect all events from transition_function keys. For each event, verify there's a test that triggers it.
Why it matters: Untested event paths may contain bugs in the data they produce or transitions they trigger.
T6: _MetaPayload.registry Not Saved/Restored
What: FSMBehaviourBaseCase.setup_class() saves and clears _MetaPayload.registry, and teardown_class() restores it. Test classes that override these methods without calling super() corrupt the payload registry for subsequent tests.
Search pattern: Look for setup_class or teardown_class overrides in test files that don't call super().
Why it matters: Corrupted registry causes payload deserialization failures in unrelated tests, leading to flaky CI.
Low (L) — Code quality and maintainability
L1: Dead Code
What: Unused instance variables set in __init__ or setup() but never read. Unreachable code after unconditional return/raise/break.
L2: Stale Imports and Unused Events
What: Imports that are no longer used. Event enum members with no references.
L3: Docstring / Transition Function Drift
What: Comments or docstrings describing the FSM that don't match the actual transition_function definition.
L4: Logging Hygiene (Secret / PII Leakage)
What: Behaviour and connection log lines that interpolate variables holding credentials, signed material, or full HTTP responses expose secrets to log aggregators and on-disk logs. Once a secret is in a log stream, it must be assumed compromised.
Search patterns:
- Variable name regex inside
logger.*(f"... {x} ...", logger.*("... %s ...", x), logger.*("... " + x):
private_key, priv_key, pk, sk, secret, token, api_key, apikey, password, passphrase, bearer, mnemonic, seed, nonce (when used as crypto material, not RPC nonce)
- Whole-object logging that may contain nested credentials:
signed_order, signed_tx, signed_message, credentials, headers (when headers contain Authorization)
request, response objects logged in full
- Full HTTP body / response logging that may include access tokens or PII
Why it's L not C: the agent doesn't crash and consensus isn't broken; it's a security-posture issue. Severity escalates if the leaked material grants production access.
Fix: redact / hash before logging; log only metadata (key prefix, length); log structured fields rather than entire objects.
L5: Numeric Precision in Financial Logic
What: Common precision footguns in code that touches token amounts, prices, or financial calculations:
float arithmetic on token amounts (silent rounding accumulates over many trades)
- Mixing 6-decimal (USDC) and 18-decimal (most ERC-20) without explicit unit tracking
== / != between two values where at least one is a float
- Hardcoded float constants used in
int(... * 10**N) conversions (exact bits of the float matter)
- Arithmetic on results of older Solidity calls without checking for negative / overflow
Search patterns:
float( inside files that handle on-chain balances or token amounts
- Hardcoded float literals (e.g.
0.089935) used in conversions
== / != between two values when at least one is provably float
Decimal mixed with float in the same expression
Bug example:
FALLBACK_POL_TO_USD_RATE = 0.089935
amount_usd = pol_amount * FALLBACK_POL_TO_USD_RATE
amount_usd_wei = int(amount_usd * (10**18))
Fix: use integer wei (or Decimal with explicit precision); track decimals via a typed wrapper; never compare floats with ==.
False Positive Guidance
These patterns look suspicious but are correct by design. Do NOT report them:
synchronized_data.update() return value not captured
synchronized_data.update(synchronized_data_class=SomeSyncData, **kwargs)
Why it's fine: update() mutates the underlying AbciAppDB in-place before returning a new wrapper object. The mutation happens regardless of whether the return value is captured.
check_tx not tracking offences while deliver_tx does
Why it's fine: Offence tracking must be deterministic (consensus-critical). check_tx is non-deterministic (per-node mempool validation), so offence tracking there would break consensus.
sys.getsizeof() for transaction size validation
Why it's fine: Provides a conservative upper bound vs raw byte length. The overhead is constant and negligible against the ~1 MiB transaction size limit.
_is_healthy = False after successful Tendermint reset
Why it's fine: Intentional per-cycle state reset so the next _start_reset() begins a fresh health check cycle.
Payload registry mutations during import
Why it's fine: _MetaPayload registers payload classes at class creation time. Class creation is single-threaded in Python; the GIL protects simple dict writes during import.
Test helpers using stub signatures or contract IDs
Why it's fine: Intentional separation of concerns. Behaviour tests validate flow logic, not serialization correctness. Using stubs is correct test design.
Dead timeouts or unused events in test_* skills
Skills whose directory name starts with test_ (e.g. test_abci, test_solana_tx_abci, test_ipfs_abci) are test/scaffold skills — they exist to exercise and test the framework, not as production FSM apps. Patterns that look like bugs in production skills are often intentional in test skills:
event_to_timeout entries for events not in any transition_function (C4) — may exist to test that SharedState.setup() correctly wires timeout values from params
- Unused event enum members (M2) — may exist to test event handling infrastructure
- Incomplete round event testing (T5) — test skills often have minimal test coverage by design
When auditing test skills: Note them in the report under a separate "Test/Scaffold Skills" section rather than as findings. Only flag issues in test skills if they would cause the test itself to fail or produce incorrect test results.
Mutable class-level dicts/lists in BaseBehaviour subclasses
class MyBehaviour(BaseBehaviour):
local_params: Dict[str, Any] = {}
collected_data: List[str] = []
Why it's fine: Behaviours are single-instance per agent. These class-level mutables are used as instance state and are typically reset at the start of async_act(). This is NOT the same as the ([],) * n bug (C1) which creates shared references within a single data structure. Only flag this if you can demonstrate that stale state from a previous round cycle actually causes a bug.
Scope Extensions
Auditing customs/ Strategies
customs/ directories (e.g. customs/kelly_criterion/, customs/fixed_bet/) are loaded by IPFS hash at runtime via FILE_HASH_TO_STRATEGIES and execute logic that drives the FSM's financial actions (e.g. bet sizing). They are out of scope for skills/-only audits but receive zero coverage today.
When to extend: if the agent's aea-config.yaml or any services/*/service.yaml references FILE_HASH_TO_STRATEGIES (or a similarly named param mapping IPFS hashes to strategy modules), audit those strategy directories with a reduced checklist.
Reduced checklist for customs/ modules:
- L4 (logging hygiene) — strategies often log inputs and intermediate values
- L5 (numeric precision) — strategies typically compute bet sizing, expected value, Kelly fraction, etc.
- M1-style: type contracts on entry/exit functions (what does the calling behaviour expect back?)
- Error handling: do strategy functions degrade gracefully or raise into the calling behaviour? An uncaught exception in a strategy crashes the calling behaviour (audit-resilience Category A).
Skip: FSM-only checks (C3, C4, M2, M3, M5, M7, M8, T*) — there is no FSM in a strategy.
Auditing service.yaml Overrides
Both this skill and audit-resilience historically read package source only and miss runtime parameter overrides. If the audit scope is a service (not just a skill) and services/*/service.yaml exists:
- Parse each service's
overrides block. List every parameter override, its target skill, and the override value.
- For overrides that change behaviour-controlling parameters (timeouts, retry counts, URLs, contract addresses, feature flags), flag if the override:
- Changes the value type from the default declared in the skill's
models.py (e.g. int default, string override)
- Sets a value that conflicts with
db_pre_conditions (e.g. an override that expects a key not provided by an upstream round)
- Disables a safety mechanism present in defaults (retry count to 0, timeout to a very large value,
verify=False)
- When multiple
services/*/service.yaml exist for the same agent (e.g. trader_pearl/ vs polymarket_trader/), build a comparison table — drift between deployments is a footgun.
- Note: env-var-driven overrides (
${OAR_API_KEY:str:dummy}) hide values entirely. Flag any param marked sensitive (URL, key, address) that defaults to dummy / empty / placeholder — production deployments depend on the env var being set.
Methodology Guardrails
These guardrails reduce false positives observed in prior audit runs. Apply them before flagging any finding.
Verify every sub-agent finding before accepting it
The Step 2 Explore agents return candidate findings with candidate severities, not final ones. The spawning agent (you) MUST hand-verify each candidate against the source before promoting it into the report. Sub-agents over-report because they lack the cross-cutting view across the audit scope.
For each candidate finding from a sub-agent, before accepting:
- Read the cited
file:line in the actual source. Confirm the bug exists as described — not a misread of static type vs runtime type, not a paraphrase that drifts from the actual code, not a citation pointing at the wrong line.
- Confirm the bug is real. Apply the data-type trace guardrail (below). Apply the framework-scheduled-event carve-out (below). Check the False Positive Guidance section.
- Re-evaluate severity. Sub-agents systematically over-classify. Concretely:
- "Unused / dead code" (initial-only attributes, dead comments, vestigial fields) is almost always L, not C. Critical requires runtime impact.
- A misnamed-but-harmless attribute is L, not C. Promote only if you can demonstrate a runtime path that reads / writes through it incorrectly.
- Configuration that "looks misleading" is L unless misleading the reader produced (or is highly likely to produce) a downstream bug.
- Consolidate split findings. If three sub-agent findings point at one root cause (e.g. three separate Cs that all stem from one mis-typed
AgentDB accessor), report ONCE at the appropriate severity, not three times.
- Demote liberally. If you can't reproduce the sub-agent's reasoning from the source in under a minute, the finding is probably wrong — drop it or downgrade to L with an "informational" note.
Sub-agent classification is input, not output. The report is your judgment, not the union of subagent outputs.
Force the data-type trace before flagging type mismatches
Many false positives in prior runs (M5 mismatches, T2 wrong-base-class claims, "non-existent payload field" claims) came from agents reasoning about annotated types instead of runtime types. In open-autonomy, payload fields and DB-backed properties are JSON-deserialized — the runtime type may differ from the annotation.
When a check requires reasoning about a value's runtime type, before flagging:
- Locate the last assignment to the variable along the call path (do not stop at the type annotation).
- If that assignment is
json.loads(some_field), self.db.get_strict(key), or a BaseTxPayload field whose value was JSON-serialized at write time, the runtime type is whatever the JSON producer wrote — which may be dict, list, str, int. Static annotations alone are not authoritative here.
- For framework-DB-backed properties, trace from
selection_key → property body → any json.loads(...) to determine the actual runtime type.
- Do NOT flag a type mismatch unless you can name both the expected type AND the actual runtime type with a
file:line citation for each.
A concrete past failure: a discovery agent claimed isinstance(actions_data, dict) was dead because synced_data.actions was annotated Optional[List[...]]. The annotation was a stale guess; the runtime value came from json.loads(content) and could be either a dict or a list.
Strip framework-scheduled events from end_block completeness checks
C3 explicitly carves these out (see C3 above). Repeating here as a guardrail because prior runs surfaced this false positive (a discovery agent flagged Event.ROUND_TIMEOUT as "missing from CheckFundsRound.end_block()" — ROUND_TIMEOUT is scheduled by the framework via event_to_timeout, not emitted by end_block).
Before flagging an event as missing from end_block():
- If the event appears in
event_to_timeout for the round, skip it — the framework schedules the transition.
- If the event appears in the round's transition function but is neither a base-class event (
done, none, no_majority, fail) nor in event_to_timeout, then it is genuinely missing — flag it under C3.
Single-finding-per-bug discipline
If two checks (e.g. C3 dead transition + M8 misleading attribute) flag the same underlying bug, report once with both check IDs cited, not twice.
Coverage Limitations
This skill audits FSM correctness and safety in skills/. The following are explicitly NOT covered — the audit report should call this out so consumers know what they still need to do:
- External request resilience → run
audit-resilience (companion skill).
- Security review → run
/security-review. This skill does not audit private-key handling, env-var injection in skill.yaml, eval / exec patterns, unsafe deserialization (pickle.loads), or supply-chain risks.
- Numeric precision in financial logic is L5-checked at a syntactic level only; mathematical correctness of strategy logic is out of scope.
- Dependency CVEs → run
pip-audit / safety check after this audit.
- Smart-contract audit → out of scope; covered by Solidity-specific tooling.
- Performance / gas optimisation → out of scope.
Include a "What this audit did not cover" section in every report.
Analysis Procedure
Follow these steps in order:
Step 0: Run Existing CLI Analysis Tools
Before manual inspection, run the framework's built-in analysis commands to catch structural issues automatically. Use the Bash tool to execute these:
autonomy analyse fsm-specs --package <skill-path>
autonomy analyse handlers
autonomy analyse dialogues
autonomy analyse docstrings
Critical — filter unscoped output against dev packages only. handlers / dialogues / docstrings walk every package under packages/, including framework-vendored ones in the third_party section of packages/packages.json. Findings against third_party packages belong to the upstream repo, not this audit. The composition / cross-skill analysis later in the audit STILL needs full visibility into those packages — but their CLI-tool errors are out-of-scope as findings.
Build the dev set once and reuse it across all unscoped tools:
jq -r '.dev | keys[]' packages/packages.json
For each error line emitted by handlers / dialogues / docstrings, drop the line if the cited path resolves under a third_party package (i.e. its <author>/<type>s/<name> is not in the dev set). The remaining lines are in-scope findings. Note in the report how many lines were filtered and from which third-party paths, so the consumer can see what was dropped and why.
fsm-specs is already scoped — invoke it only on dev skills (and only on the $ARGUMENTS subset if provided).
These tools catch issues the manual audit does not need to duplicate:
fsm-specs validates that the FSM spec file, the docstring, and the transition_function definition are all consistent (scoped to target skill — invoke per dev skill)
handlers verifies required handlers are defined and properly configured (repo-wide; filter output to dev)
dialogues verifies dialogue definitions match protocol specifications (repo-wide; filter output to dev)
docstrings checks the AbciApp class docstring matches the actual state machine definition (repo-wide; filter output to dev)
Include the (filtered) output of these tools in the report. If any in-scope error remains, report it as a finding. Then proceed with the manual checks below, which cover semantic and logic issues these tools cannot detect — and which DO read across into library / third_party packages where composition requires it.
Tooling-unavailable case. If the CLI tools fail with No module named 'packages.valory.skills.abstract_round_abci' (or similar import errors), the local packages/ tree is missing library skills that the project policy doesn't sync (some downstream repos forbid autonomy packages sync --all because it bypasses a curated dependency set). This is a project policy choice, not a bug. Note in the report: "CLI tools could not run because library skills are not in the local tree; proceeded with manual checks." Do NOT report this as a finding, and do NOT block the audit on it.
Step 1: Discovery
Determine the scope of the audit:
# If $ARGUMENTS is provided, use those paths directly
# Otherwise, discover all skill directories:
Glob pattern: packages/**/skills/*/rounds.py
Build a list of skill directories to audit. Each skill directory should contain some subset of: rounds.py, behaviours.py, payloads.py, models.py, __init__.py, and a tests/ subdirectory.
Step 2: Parallel Analysis
Launch up to 3 parallel Explore agents, dividing skills evenly among them. Each agent should:
-
Read the key files in each assigned skill:
rounds.py — AbciApp definition, round classes, transition function, events
behaviours.py — behaviour classes, matching_round links
payloads.py — payload definitions
models.py — if it exists, for parameter and data model definitions
tests/test_rounds.py — round test classes
tests/test_behaviours.py — behaviour test classes
-
Run all checks from the Audit Checklist (C1–C6, H1–H4, M1–M8, T1–T6, L1–L5)
-
Return findings as a structured list with:
- Check ID (e.g. C1, H2)
- File path and line number
- Code snippet showing the issue
- Explanation of why it's a problem
- Suggested fix
Give each agent the full checklist (copy the relevant sections from this skill) so it can work independently.
Step 3: Structural Analysis
After agents return, perform a final cross-cutting check:
- For composed apps, verify chain consistency (H2) across skill boundaries
- Check for conflicting event names or round IDs across composed skills
- Verify
cross_period_persisted_keys cover all necessary data flow
Step 4: Generate Report
Merge all findings into a single structured report.
Output Format
Present the audit report as follows:
# FSM Audit Report
**Scope:** [list of audited skill paths]
**Date:** [current date]
## Critical Findings
### [C#]: [Title]
- **File:** `path/to/file.py:line`
- **Issue:** [description]
- **Code:**
```python
[problematic code snippet]
- Fix:
[corrected code snippet]
High Findings
[same format]
Medium Findings
[same format]
Low Findings
[same format]
Summary
| Severity | Count |
|---|
| Critical | N |
| High | N |
| Medium | N |
| Low | N |
Notes
[Any false-positive exclusions or caveats about the audit scope]
What This Audit Did Not Cover
- External-request resilience (run
/audit-resilience)
- Security review of credential handling, env-var injection, unsafe deserialization (run
/security-review)
- Mathematical correctness of strategy logic (only syntactic L5 checks were applied)
- Dependency CVEs (run
pip-audit / safety check)
- Smart-contract audit, gas optimisation
- [Add any further scope exclusions specific to this audit run, e.g. "CLI tools could not run because library skills are not in the local tree"]
If no findings at a severity level, include the section header with "No findings." underneath.