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 thedegraded_inputsguard.test_no_ktp_panel_reads_a_field_that_does_not_exist— catches a renamed field in anunwrapas well as in aline_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¶
Or ask for the path directly: