Skip to content

Risk Factors

Four of the six KTP v2.0.0 Risk Factor inputs, emitted once per poll. Each is a stress term in [0,1] where 1 is maximum stress.

Factor Measures
adversarial_pressure How much hostile activity the environment is currently showing.
evidence_density How much of the observable surface is producing findings.
trust_trend Whether conditions are worsening.
update_resistance How stale the observation surface has become.

Saturation points

A stress term needs a ceiling, or the scale means nothing. These are the observation counts at which each contributing measure reaches maximum stress:

Measure Saturates at
Finding score 100
Distinct tactics 8
Chain progression 1.0
Concurrent connections 500
Distinct peers 50
Egress depth 10
Heartbeat depth 30
Candidate runtimes 20
Observation depth 3600 s

The eleven feeds

Each factor is built from feeds, and each feed either reported this poll or did not. feeds_active and feeds_total travel with every snapshot.

Factor Feeds Needs root
adversarial_pressure finding severity, agent_tactics, chain_progression 2 of 3 do
evidence_density connection and peer saturation, egress depth, runtime candidates no
trust_trend finding trend, tactic trend 1 of 2 does
update_resistance observation depth, silent processes no

What that means on an unprivileged host

adversarial_pressure never drops below 0.714, and reads 1.0 whenever a finding lands

Nothing is wrong. Endpoint Security needs root, so two of that factor's three feeds — agent_tactics and chain_progression — are unobserved and resolve to 1.0 each. Two of three pinned at maximum puts a floor under the factor no matter how quiet the machine is.

trust_trend behaves the same way at one of two feeds.

Feed coverage pins at 73% (8 of 11) unprivileged. Run the sensor with sudo and enable_esf and all eleven report — on a genuinely quiet host all four factors then sit low with coverage at 100%, and a rise means what a rise should mean.

The rule for every panel and every alert

Read ktp.degraded alongside the value. It is on every data point, with ktp.feeds_active and ktp.feeds_total behind it.

# Wrong. Fires permanently on any unprivileged host.
ktp_risk_factor_adversarial_pressure > 0.7

# Right. Fires on a hostile environment, not on a blind sensor.
ktp_risk_factor_adversarial_pressure{ktp_degraded="false"} > 0.7

# Worth its own alert, at a far lower urgency: the sensor went partly blind.
ktp_risk_factor_adversarial_pressure{ktp_degraded="true"}

The third one matters as much as the second. Suppressing the degraded series rather than alerting on it separately trades a false positive for a blind spot, which is the wrong direction for a detector.

This is the same argument the host-plane row makes. An empty kill-chain row means the host plane is off, not that the host is clean; a Risk Factor at 1.0 means the environment is hostile or nothing could see it. Two opposite situations wearing the same number, and in both cases the authoritative answer is a separate series — shadowclaw.esf.running there, ktp.degraded here.

The dashboard row enforces it

The bottom row of the shipped dashboard is Kinetic Trust Protocol — conditions, and how much of them we could see. Its layout is the rule above expressed as a dashboard: no number appears without the coverage that qualifies it.

Panel Reads
Inputs unobserved degraded_count. Leftmost deliberately — it is the panel that tells you whether the four to its right mean anything.
Feed coverage feed_coverage, the share of the eleven declared feeds that reported.
Four factor stats The last measured value of each, filtered with degraded_inputs !~ ".*<factor>.*". A poll where the input was substituted is excluded, so the panel reads unobserved rather than showing the 1.0 substitute in red.
Risk Factors over time All four, every poll, substituted values included. Nothing is filtered here: a line that vanishes when the sensor goes dark is indistinguishable from one that fell to calm.
Could we see it? Coverage, unobserved count, and silent processes, sitting beside the chart so the two are read together. A step up on the left that coincides with a fall here is the sensor losing sight, not the host getting worse.
Risk Factor snapshots One line per poll, emitted on quiet polls too, with a summary naming any substituted input.

Three tests in tests/test_otlpevent.py hold that shape:

  • test_no_stat_panel_shows_a_substituted_value_as_a_measurement — fails if a single-number panel ever unwraps a factor without the degraded_inputs guard.
  • test_no_ktp_panel_reads_a_field_that_does_not_exist — catches a renamed field in an unwrap as well as in a line_format.
  • test_the_factor_names_the_panels_filter_on_are_real_factors — catches a typo that would silently match every poll.

Silent processes

silent_processes counts processes ShadowClaw tracked that stopped appearing. Their windows are empty and reported as empty rather than dropped, for the same reason as everything else on this page: a host that vanished and a host that reported calm must not render identically.

It is also emitted as its own metric, ktp.aggregation.silent_processes.

On the wire

The dashboard reads logs, not metrics, because the documented path posts straight to Loki with --no-otlp-metrics. So the values ship as a log event with everything flat in the body:

{
  "event_name": "shadowclaw.ktp.risk_factors",
  "summary": "2 of 4 inputs unobserved (adversarial_pressure,trust_trend) -- values include maximum-stress substitutes, not measurements",
  "degraded_inputs": "adversarial_pressure,trust_trend",
  "degraded_count": 2,
  "inputs_total": 4,
  "fully_observed": false,
  "feeds_active": 8,
  "feeds_total": 11,
  "feed_coverage": 0.7273,
  "silent_processes": 0,
  "adversarial_pressure": 0.714286,
  "evidence_density": 0.08,
  "trust_trend": 1.0,
  "update_resistance": 0.17
}

Flat and in the body because that is what a query can reach: every panel parses with | json, and a nested attribute is neither a label nor a parseable field. degraded_inputs is a comma-joined string for the same reason signals is on a finding — LogQL matches it with a regex, and an array would not survive the parse.

The gauges still exist and carry the same values with ktp.degraded as an attribute; they are what the PromQL above queries. Send them to a collector or Prometheus, not to Loki.

Reading it locally

tail -f "$(python3 -m shadowclaw --ledger path | grep ktp-risk)"

Or ask for the path directly:

python3 -m shadowclaw --ledger path

Next