feat(shell): initial decnet_behave_shell spec + tests
Shell-session behavioral observation registry layered on core. SPDX: GPL-3.0-or-later (code) / CC-BY-SA-4.0 (attribution-recipes.md).
This commit is contained in:
107
BEHAVE-SHELL/tests/test_envelope.py
Normal file
107
BEHAVE-SHELL/tests/test_envelope.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Registry-aware envelope tests for BEHAVE-SHELL.
|
||||
|
||||
Structural envelope tests (window, confidence bounds, schema version, etc.)
|
||||
live in `decnet-behave-core`'s test suite. This file exercises the SHELL-
|
||||
SPECIFIC validation: that BEHAVE-SHELL's Observation subclass rejects
|
||||
primitives not in the shell registry and rejects values that violate the
|
||||
per-primitive ValueTypeSpec.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from decnet_behave_shell.spec import Observation, Window
|
||||
|
||||
|
||||
def _make(primitive: str = "motor.keystroke_cadence", value="steady", **kwargs) -> Observation:
|
||||
base = dict(
|
||||
primitive=primitive,
|
||||
value=value,
|
||||
confidence=0.8,
|
||||
window=Window(start_ts=1.0, end_ts=2.0),
|
||||
source="test/sensor",
|
||||
)
|
||||
base.update(kwargs)
|
||||
return Observation(**base)
|
||||
|
||||
|
||||
def test_unknown_primitive_rejected():
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
_make(primitive="motor.nonexistent", value="whatever")
|
||||
assert "unknown primitive" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_categorical_value_outside_allowed_rejected():
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
_make(primitive="motor.keystroke_cadence", value="not_a_real_value")
|
||||
assert "not in allowed set" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_categorical_wrong_type_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
_make(primitive="motor.keystroke_cadence", value=42)
|
||||
|
||||
|
||||
def test_numeric_min_bound_enforced():
|
||||
with pytest.raises(ValidationError):
|
||||
_make(primitive="toolchain.c2.beacon_interval_ms", value=-1)
|
||||
|
||||
|
||||
def test_numeric_accepts_valid():
|
||||
obs = _make(primitive="toolchain.c2.beacon_interval_ms", value=60_000)
|
||||
assert obs.value == 60_000
|
||||
|
||||
|
||||
def test_numeric_rejects_bool():
|
||||
# bool is a subclass of int — must be rejected explicitly.
|
||||
with pytest.raises(ValidationError):
|
||||
_make(primitive="toolchain.c2.beacon_interval_ms", value=True)
|
||||
|
||||
|
||||
def test_hash_requires_nonempty_string():
|
||||
with pytest.raises(ValidationError):
|
||||
_make(primitive="toolchain.tls.ja3_client", value="")
|
||||
|
||||
|
||||
def test_array_validates_elements():
|
||||
obs = _make(
|
||||
primitive="toolchain.ssh.kex_algorithm_order",
|
||||
value=["curve25519-sha256", "ecdh-sha2-nistp256"],
|
||||
)
|
||||
assert isinstance(obs.value, list)
|
||||
|
||||
|
||||
def test_array_rejects_non_list():
|
||||
with pytest.raises(ValidationError):
|
||||
_make(primitive="toolchain.ssh.kex_algorithm_order", value="not a list")
|
||||
|
||||
|
||||
def test_bool_primitive_accepts_bool():
|
||||
obs = _make(primitive="toolchain.protocol_abuse.mitm6_signature", value=True)
|
||||
assert obs.value is True
|
||||
|
||||
|
||||
def test_bool_primitive_rejects_int():
|
||||
with pytest.raises(ValidationError):
|
||||
_make(primitive="toolchain.protocol_abuse.mitm6_signature", value=1)
|
||||
|
||||
|
||||
def test_free_string_primitive_accepts_arbitrary_string():
|
||||
obs = _make(primitive="environmental.locale", value="pt-BR")
|
||||
assert obs.value == "pt-BR"
|
||||
|
||||
|
||||
def test_extra_fields_still_forbidden_via_subclass():
|
||||
# Inherited from base — the subclass shouldn't relax this.
|
||||
with pytest.raises(ValidationError):
|
||||
Observation(
|
||||
primitive="motor.keystroke_cadence",
|
||||
value="steady",
|
||||
confidence=0.5,
|
||||
window=Window(start_ts=1.0, end_ts=2.0),
|
||||
source="test/sensor",
|
||||
unknown_field="oops",
|
||||
)
|
||||
89
BEHAVE-SHELL/tests/test_event_adapter.py
Normal file
89
BEHAVE-SHELL/tests/test_event_adapter.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""DECNET interop tests for the event adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet_behave_shell.spec import (
|
||||
Observation,
|
||||
Window,
|
||||
event_topic_for,
|
||||
from_event_payload,
|
||||
to_event_payload,
|
||||
)
|
||||
|
||||
|
||||
def _obs(**kwargs) -> Observation:
|
||||
base = dict(
|
||||
primitive="motor.keystroke_cadence",
|
||||
value="steady",
|
||||
confidence=0.8,
|
||||
window=Window(start_ts=1.0, end_ts=2.0),
|
||||
source="test/sensor",
|
||||
)
|
||||
base.update(kwargs)
|
||||
return Observation(**base)
|
||||
|
||||
|
||||
def test_topic_derivation_uses_attacker_observation_prefix():
|
||||
topic = event_topic_for("motor.keystroke_cadence")
|
||||
assert topic == "attacker.observation.motor.keystroke_cadence"
|
||||
|
||||
|
||||
def test_topic_handles_deeply_nested_primitive():
|
||||
topic = event_topic_for("toolchain.protocol_abuse.smb_dialect")
|
||||
assert topic == "attacker.observation.toolchain.protocol_abuse.smb_dialect"
|
||||
|
||||
|
||||
def test_payload_excludes_envelope_level_fields():
|
||||
obs = _obs()
|
||||
payload = to_event_payload(obs)
|
||||
# These fields ride at the DECNET Event envelope, not in the payload body.
|
||||
assert "id" not in payload
|
||||
assert "ts" not in payload
|
||||
assert "v" not in payload
|
||||
# These remain in the payload body.
|
||||
assert payload["primitive"] == "motor.keystroke_cadence"
|
||||
assert payload["value"] == "steady"
|
||||
assert payload["confidence"] == 0.8
|
||||
assert payload["source"] == "test/sensor"
|
||||
|
||||
|
||||
def test_round_trip_through_event_payload():
|
||||
obs = _obs(
|
||||
evidence_ref="session_X/keystrokes[0:42]",
|
||||
identity_ref="00000000000000000000000000000001",
|
||||
)
|
||||
payload = to_event_payload(obs)
|
||||
reconstructed = from_event_payload("motor.keystroke_cadence", payload)
|
||||
|
||||
# id and ts will differ (auto-generated on reconstruct), v defaults match.
|
||||
assert reconstructed.primitive == obs.primitive
|
||||
assert reconstructed.value == obs.value
|
||||
assert reconstructed.confidence == obs.confidence
|
||||
assert reconstructed.window == obs.window
|
||||
assert reconstructed.source == obs.source
|
||||
assert reconstructed.evidence_ref == obs.evidence_ref
|
||||
assert reconstructed.identity_ref == obs.identity_ref
|
||||
assert reconstructed.v == obs.v
|
||||
|
||||
|
||||
def test_from_event_payload_rejects_topic_payload_mismatch():
|
||||
obs = _obs()
|
||||
payload = to_event_payload(obs)
|
||||
# payload still carries primitive="motor.keystroke_cadence"; reconstructing
|
||||
# under a different topic-derived primitive must refuse rather than silently
|
||||
# adopt the wire-side value (see decnet/bus/base.py:60-76 for the same anti-
|
||||
# spoofing discipline).
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
from_event_payload("toolchain.tls.ja3_client", payload)
|
||||
|
||||
|
||||
def test_payload_is_json_serializable():
|
||||
import json
|
||||
obs = _obs(primitive="toolchain.ssh.kex_algorithm_order", value=["a", "b"])
|
||||
payload = to_event_payload(obs)
|
||||
serialized = json.dumps(payload)
|
||||
deserialized = json.loads(serialized)
|
||||
assert deserialized["value"] == ["a", "b"]
|
||||
134
BEHAVE-SHELL/tests/test_primitives.py
Normal file
134
BEHAVE-SHELL/tests/test_primitives.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Registry coverage tests.
|
||||
|
||||
Asserts that every primitive listed in scratchpad.md's tables has exactly one
|
||||
entry in PRIMITIVE_REGISTRY. Drift-detector — failing this test means
|
||||
scratchpad.md and the registry have diverged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from decnet_behave_shell.spec import PRIMITIVE_REGISTRY, ValueKind
|
||||
|
||||
# Primitive paths expected by scratchpad.md (hand-extracted; v0.1).
|
||||
EXPECTED_PRIMITIVES = {
|
||||
# motor.*
|
||||
"motor.keystroke_cadence",
|
||||
"motor.motor_stability",
|
||||
"motor.error_correction",
|
||||
"motor.command_chunking",
|
||||
"motor.paste_burst_rate",
|
||||
"motor.input_modality",
|
||||
"motor.shell_mastery.tab_completion",
|
||||
"motor.shell_mastery.shortcut_usage",
|
||||
"motor.shell_mastery.pipe_chaining_depth",
|
||||
# cognitive.*
|
||||
"cognitive.cognitive_load",
|
||||
"cognitive.exploration_style",
|
||||
"cognitive.planning_depth",
|
||||
"cognitive.tool_vocabulary",
|
||||
"cognitive.inter_command_latency_class",
|
||||
"cognitive.inter_command_consistency",
|
||||
"cognitive.command_branch_diversity",
|
||||
"cognitive.feedback_loop_engagement",
|
||||
"cognitive.error_resilience.retry_tactic",
|
||||
"cognitive.error_resilience.frustration_typing",
|
||||
"cognitive.error_resilience.fallback_to_man",
|
||||
# temporal.*
|
||||
"temporal.session_timing",
|
||||
"temporal.session_duration",
|
||||
"temporal.escalation_pattern",
|
||||
"temporal.persistence",
|
||||
"temporal.lifecycle_markers.landing_ritual",
|
||||
"temporal.lifecycle_markers.exit_behavior",
|
||||
"temporal.lifecycle_markers.idle_periodicity",
|
||||
# operational.*
|
||||
"operational.opsec_discipline",
|
||||
"operational.cleanup_behavior",
|
||||
"operational.objective",
|
||||
"operational.multi_actor_indicators",
|
||||
# environmental.*
|
||||
"environmental.keyboard_layout",
|
||||
"environmental.locale",
|
||||
"environmental.numpad_usage",
|
||||
"environmental.terminal_multiplexer",
|
||||
"environmental.shell_type",
|
||||
# cultural.*
|
||||
"cultural.meal_break_gaps",
|
||||
"cultural.periodic_micro_pauses",
|
||||
"cultural.dst_behavior",
|
||||
"cultural.weekend_cadence",
|
||||
"cultural.holiday_gaps",
|
||||
# emotional_valence.*
|
||||
"emotional_valence.valence",
|
||||
"emotional_valence.arousal",
|
||||
"emotional_valence.stress_response",
|
||||
"emotional_valence.frustration_venting",
|
||||
# toolchain.tls.*
|
||||
"toolchain.tls.ja3_client",
|
||||
"toolchain.tls.ja3s_server",
|
||||
"toolchain.tls.ja4_client",
|
||||
"toolchain.tls.ja4s_server",
|
||||
"toolchain.tls.jarm_server",
|
||||
"toolchain.tls.tls_cert_simhash",
|
||||
# toolchain.transport.*
|
||||
"toolchain.transport.tcp_stack",
|
||||
"toolchain.transport.h2_akamai_fingerprint",
|
||||
"toolchain.transport.quic_client",
|
||||
# toolchain.ssh.*
|
||||
"toolchain.ssh.hassh_client",
|
||||
"toolchain.ssh.hassh_server",
|
||||
"toolchain.ssh.ssh_client_banner",
|
||||
"toolchain.ssh.kex_algorithm_order",
|
||||
# toolchain.http.*
|
||||
"toolchain.http.user_agent_tool_class",
|
||||
"toolchain.http.header_order_fingerprint",
|
||||
"toolchain.http.body_oddities",
|
||||
# toolchain.c2.*
|
||||
"toolchain.c2.beacon_family",
|
||||
"toolchain.c2.beacon_interval_ms",
|
||||
"toolchain.c2.beacon_jitter_cv",
|
||||
"toolchain.c2.sleep_skew",
|
||||
"toolchain.c2.c2_callback_endpoint",
|
||||
"toolchain.c2.attack_software_id",
|
||||
# toolchain.protocol_abuse.*
|
||||
"toolchain.protocol_abuse.dns_exfil_tool",
|
||||
"toolchain.protocol_abuse.smb_dialect",
|
||||
"toolchain.protocol_abuse.kerberos_etype_offer",
|
||||
"toolchain.protocol_abuse.ldap_bind_pattern",
|
||||
"toolchain.protocol_abuse.responder_signature",
|
||||
"toolchain.protocol_abuse.mitm6_signature",
|
||||
# toolchain.payload.*
|
||||
"toolchain.payload.payload_simhash",
|
||||
"toolchain.payload.payload_entropy_class",
|
||||
"toolchain.payload.loader_family",
|
||||
}
|
||||
|
||||
|
||||
def test_registry_covers_expected_primitives_exactly():
|
||||
registry_keys = set(PRIMITIVE_REGISTRY.keys())
|
||||
missing = EXPECTED_PRIMITIVES - registry_keys
|
||||
extra = registry_keys - EXPECTED_PRIMITIVES
|
||||
assert not missing, f"registry missing: {sorted(missing)}"
|
||||
assert not extra, f"registry has unexpected entries: {sorted(extra)}"
|
||||
|
||||
|
||||
def test_every_primitive_has_a_valid_spec():
|
||||
for primitive, spec in PRIMITIVE_REGISTRY.items():
|
||||
if spec.kind is ValueKind.CATEGORICAL:
|
||||
assert spec.allowed, f"{primitive}: categorical must define `allowed`"
|
||||
assert all(isinstance(v, str) for v in spec.allowed)
|
||||
elif spec.kind is ValueKind.ARRAY:
|
||||
assert spec.array_of is not None, f"{primitive}: array must define `array_of`"
|
||||
assert spec.array_of is not ValueKind.ARRAY, (
|
||||
f"{primitive}: nested arrays not supported in v0.1"
|
||||
)
|
||||
|
||||
|
||||
def test_primitive_paths_are_dotted_lowercase():
|
||||
pattern = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
|
||||
for primitive in PRIMITIVE_REGISTRY:
|
||||
assert pattern.match(primitive), f"malformed primitive path: {primitive!r}"
|
||||
Reference in New Issue
Block a user