Skip to content

Commit 2331ee8

Browse files
authored
Merge pull request #4 from A3S-Lab/feat/decoupled-l3
feat: enable decoupled L3 judgment with evidence safeguards
2 parents cf93cc2 + 8c736ac commit 2331ee8

12 files changed

Lines changed: 435 additions & 10 deletions

File tree

sdk/typescript/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/typescript/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-sentry-node"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
license = "MIT"
66
description = "Native (napi) Node binding for a3s-sentry's in-process judge."

sdk/typescript/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ if (d?.verdict === "block") {
2525
// judge AND write the deny-file the kernel guards read:
2626
const r = sentry.evaluateAndEnforce(toolExec(2, ["/usr/bin/ncat", "host", "4444"]));
2727
console.log(r?.decision.verdict, r?.enforced); // "block", "/path/exec.txt"
28+
29+
// Stop after L2 and preserve an unresolved escalation for a durable external L3 worker. This runs
30+
// on the napi worker pool, so a slow L2 request does not block Node's event loop.
31+
const fast = await sentry.evaluateThroughL2(toolExec(3, ["bash", "-c", "base64 -d | sh"]));
32+
if (fast?.stageStatus === "escalated") {
33+
console.log(fast.escalationCause, fast.effectiveDecision);
34+
}
2835
```
2936

3037
A `sentry.acl` carries everything (see [`config.rs`](../../src/config.rs) for the schema):
@@ -45,6 +52,10 @@ Event builders: `toolExec`, `egress`, `fileAccess`, `dns`, `sslContent`, `securi
4552
returns the observer event JSON `evaluate` takes. `evaluate` returns `null` for an unparseable event,
4653
and a `Decision` (`{ verdict, tier, severity, reason, action? }`) otherwise — including `allow`.
4754

55+
`evaluateThroughL2` returns a promise containing `l1Decision`, optional `l2Decision`,
56+
`effectiveDecision`, `stageStatus`, and optional `escalationCause`. It never invokes L3, never
57+
resolves an outstanding escalation through `fail_closed`, and ignores speculative L3 settings.
58+
4859
## Build / test (from source)
4960

5061
```bash

sdk/typescript/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/typescript/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@a3s-lab/sentry",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Native (in-process) SDK for a3s-sentry — judge observer events through the embedded L1/L2/L3 pipeline.",
55
"license": "MIT",
66
"repository": {

sdk/typescript/src/lib.rs

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
77
use a3s_sentry::{
88
EnforceAction as CoreAction, RiskType as CoreRiskType, Sentry as CoreSentry, Severity, Tier,
9-
Verdict,
9+
ThroughL2StageStatus as CoreThroughL2StageStatus, Verdict,
1010
};
11+
use napi::{bindgen_prelude::AsyncTask, Env, Task};
1112
use napi_derive::napi;
13+
use std::sync::Arc;
1214

1315
#[napi(object)]
1416
pub struct EnforceAction {
@@ -48,10 +50,41 @@ pub struct EnforceResult {
4850
pub enforced: Option<String>,
4951
}
5052

53+
#[napi(object)]
54+
pub struct ThroughL2Result {
55+
pub l1_decision: Decision,
56+
pub l2_decision: Option<Decision>,
57+
pub effective_decision: Decision,
58+
/// `completed` | `escalated`.
59+
pub stage_status: String,
60+
/// `l1` | `l2` | `sae`, when the effective decision remains escalated.
61+
pub escalation_cause: Option<String>,
62+
}
63+
5164
/// An in-process sentry judge built from one ACL config.
5265
#[napi]
5366
pub struct Sentry {
54-
inner: CoreSentry,
67+
inner: Arc<CoreSentry>,
68+
}
69+
70+
pub struct EvaluateThroughL2Task {
71+
inner: Arc<CoreSentry>,
72+
event: String,
73+
}
74+
75+
impl Task for EvaluateThroughL2Task {
76+
type Output = a3s_sentry::ThroughL2Result;
77+
type JsValue = ThroughL2Result;
78+
79+
fn compute(&mut self) -> napi::Result<Self::Output> {
80+
self.inner
81+
.evaluate_through_l2(&self.event)
82+
.ok_or_else(|| napi::Error::from_reason("event is not parseable"))
83+
}
84+
85+
fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result<Self::JsValue> {
86+
Ok(to_through_l2_result(output))
87+
}
5588
}
5689

5790
#[napi]
@@ -60,7 +93,9 @@ impl Sentry {
6093
#[napi(factory)]
6194
pub fn create(config: String) -> napi::Result<Sentry> {
6295
CoreSentry::create(&config)
63-
.map(|inner| Sentry { inner })
96+
.map(|inner| Sentry {
97+
inner: Arc::new(inner),
98+
})
6499
.map_err(|e| napi::Error::from_reason(e.to_string()))
65100
}
66101

@@ -70,6 +105,15 @@ impl Sentry {
70105
self.inner.evaluate(&event).map(to_decision)
71106
}
72107

108+
/// Judge through L2 on the napi worker pool, preserving escalation for an external L3 worker.
109+
#[napi]
110+
pub fn evaluate_through_l2(&self, event: String) -> AsyncTask<EvaluateThroughL2Task> {
111+
AsyncTask::new(EvaluateThroughL2Task {
112+
inner: Arc::clone(&self.inner),
113+
event,
114+
})
115+
}
116+
73117
/// Judge and, on a block carrying a target, write it to the configured deny-file. `null` if the
74118
/// event isn't parseable.
75119
#[napi]
@@ -83,6 +127,27 @@ impl Sentry {
83127
}
84128
}
85129

130+
fn to_through_l2_result(result: a3s_sentry::ThroughL2Result) -> ThroughL2Result {
131+
let stage_status = match result.stage_status {
132+
CoreThroughL2StageStatus::Completed => "completed",
133+
CoreThroughL2StageStatus::Escalated => "escalated",
134+
};
135+
ThroughL2Result {
136+
l1_decision: to_decision(result.l1_decision),
137+
l2_decision: result.l2_decision.map(to_decision),
138+
effective_decision: to_decision(result.effective_decision),
139+
stage_status: stage_status.to_string(),
140+
escalation_cause: result.escalation_cause.map(|cause| {
141+
match cause {
142+
a3s_sentry::EscalationCause::L1 => "l1",
143+
a3s_sentry::EscalationCause::L2 => "l2",
144+
a3s_sentry::EscalationCause::Sae => "sae",
145+
}
146+
.to_string()
147+
}),
148+
}
149+
}
150+
86151
fn to_decision(d: a3s_sentry::Decision) -> Decision {
87152
let verdict = match d.verdict {
88153
Verdict::Allow => "allow",

sdk/typescript/test/sdk.test.mjs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,65 @@ test("L2 tier: an escalating event reaches L2 (unreachable URL → escalate, tie
110110
assert.equal(d.tier, "Llm");
111111
});
112112

113+
test("evaluateThroughL2 preserves escalation without invoking L3", async () => {
114+
const dir = mkdtempSync(join(tmpdir(), "sentry-through-l2-"));
115+
try {
116+
const marker = join(dir, "l3-called");
117+
const bin = join(dir, "mock-agent.sh");
118+
writeFileSync(
119+
bin,
120+
`#!/bin/sh\ntouch "${marker}"\necho '{"verdict":"block","severity":"critical","reason":"unexpected L3"}'\n`,
121+
);
122+
chmodSync(bin, 0o755);
123+
const s = Sentry.create(`
124+
fail_closed = true
125+
speculate = "low"
126+
llm { url = "http://127.0.0.1:1/v1" }
127+
agent { bin = "${bin}" }
128+
`);
129+
130+
const result = await s.evaluateThroughL2(
131+
fileAccess(1, "/home/u/.aws/credentials", false),
132+
);
133+
assert.equal(result.l1Decision.verdict, "escalate");
134+
assert.equal(result.l2Decision.verdict, "escalate");
135+
assert.equal(result.effectiveDecision.verdict, "escalate");
136+
assert.equal(result.effectiveDecision.tier, "Llm");
137+
assert.equal(result.stageStatus, "escalated");
138+
assert.equal(result.escalationCause, "l2");
139+
assert.throws(() => readFileSync(marker), /ENOENT/);
140+
} finally {
141+
rmSync(dir, { recursive: true, force: true });
142+
}
143+
});
144+
145+
test("incomplete ToolExec evidence stops at L1 instead of becoming allow", async () => {
146+
const s = Sentry.create(`
147+
fail_closed = false
148+
llm { url = "http://127.0.0.1:1/v1" }
149+
`);
150+
const event = JSON.stringify({
151+
event: {
152+
ToolExec: {
153+
pid: 1,
154+
argv: ["echo", "safe-prefix"],
155+
argv_truncated: true,
156+
},
157+
},
158+
});
159+
160+
const direct = s.evaluate(event);
161+
assert.equal(direct.verdict, "escalate");
162+
assert.equal(direct.tier, "Rules");
163+
assert.match(direct.reason, /incomplete ToolExec evidence/);
164+
165+
const staged = await s.evaluateThroughL2(event);
166+
assert.equal(staged.effectiveDecision.verdict, "escalate");
167+
assert.equal(staged.effectiveDecision.tier, "Rules");
168+
assert.equal(staged.l2Decision, undefined);
169+
assert.equal(staged.escalationCause, "l1");
170+
});
171+
113172
test("create() throws on a bad ACL config", () => {
114173
assert.throws(() => Sentry.create("this is not valid acl {{{"), /parsing sentry ACL config/);
115174
});

src/event.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ pub enum Event {
6161
pid: u32,
6262
#[serde(default)]
6363
argv: Vec<String>,
64+
#[serde(default)]
65+
argv_truncated: bool,
66+
#[serde(default)]
67+
argv_incomplete: bool,
6468
},
6569
SslContent {
6670
pid: u32,
@@ -105,6 +109,22 @@ pub enum Event {
105109
}
106110

107111
impl Event {
112+
/// Whether the observer could not provide the complete command evidence. A downstream model
113+
/// cannot safely infer that an omitted suffix was benign, so this condition must never become
114+
/// an ordinary allow decision.
115+
pub fn evidence_incomplete(&self) -> bool {
116+
matches!(
117+
self,
118+
Event::ToolExec {
119+
argv_truncated: true,
120+
..
121+
} | Event::ToolExec {
122+
argv_incomplete: true,
123+
..
124+
}
125+
)
126+
}
127+
108128
/// The variant name — the L1 rule `on` selector (`"ToolExec"`, `"SslContent"`, …).
109129
pub fn name(&self) -> &'static str {
110130
match self {

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub use event::{Event, Identity, ObservedEvent};
3737
pub use inline::{Direction, InlineDecision, Redaction};
3838
pub use llm::LlmJudge;
3939
pub use metrics::Metrics;
40-
pub use pipeline::{Judge, Pipeline};
40+
pub use pipeline::{EscalationCause, Judge, Pipeline, ThroughL2Result, ThroughL2StageStatus};
4141
pub use rules::{default_rules, LiveRules, RuleEngine, RuleSpec};
4242
pub use sae::{FeatureDict, FeatureLabel, SaeJudge};
4343
pub use sdk::Sentry;

0 commit comments

Comments
 (0)