Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdk/typescript/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion sdk/typescript/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "a3s-sentry-node"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "MIT"
description = "Native (napi) Node binding for a3s-sentry's in-process judge."
Expand Down
11 changes: 11 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ if (d?.verdict === "block") {
// judge AND write the deny-file the kernel guards read:
const r = sentry.evaluateAndEnforce(toolExec(2, ["/usr/bin/ncat", "host", "4444"]));
console.log(r?.decision.verdict, r?.enforced); // "block", "/path/exec.txt"

// Stop after L2 and preserve an unresolved escalation for a durable external L3 worker. This runs
// on the napi worker pool, so a slow L2 request does not block Node's event loop.
const fast = await sentry.evaluateThroughL2(toolExec(3, ["bash", "-c", "base64 -d | sh"]));
if (fast?.stageStatus === "escalated") {
console.log(fast.escalationCause, fast.effectiveDecision);
}
```

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

`evaluateThroughL2` returns a promise containing `l1Decision`, optional `l2Decision`,
`effectiveDecision`, `stageStatus`, and optional `escalationCause`. It never invokes L3, never
resolves an outstanding escalation through `fail_closed`, and ignores speculative L3 settings.

## Build / test (from source)

```bash
Expand Down
4 changes: 2 additions & 2 deletions sdk/typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion sdk/typescript/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@a3s-lab/sentry",
"version": "0.1.0",
"version": "0.2.0",
"description": "Native (in-process) SDK for a3s-sentry — judge observer events through the embedded L1/L2/L3 pipeline.",
"license": "MIT",
"repository": {
Expand Down
71 changes: 68 additions & 3 deletions sdk/typescript/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

use a3s_sentry::{
EnforceAction as CoreAction, RiskType as CoreRiskType, Sentry as CoreSentry, Severity, Tier,
Verdict,
ThroughL2StageStatus as CoreThroughL2StageStatus, Verdict,
};
use napi::{bindgen_prelude::AsyncTask, Env, Task};
use napi_derive::napi;
use std::sync::Arc;

#[napi(object)]
pub struct EnforceAction {
Expand Down Expand Up @@ -48,10 +50,41 @@ pub struct EnforceResult {
pub enforced: Option<String>,
}

#[napi(object)]
pub struct ThroughL2Result {
pub l1_decision: Decision,
pub l2_decision: Option<Decision>,
pub effective_decision: Decision,
/// `completed` | `escalated`.
pub stage_status: String,
/// `l1` | `l2` | `sae`, when the effective decision remains escalated.
pub escalation_cause: Option<String>,
}

/// An in-process sentry judge built from one ACL config.
#[napi]
pub struct Sentry {
inner: CoreSentry,
inner: Arc<CoreSentry>,
}

pub struct EvaluateThroughL2Task {
inner: Arc<CoreSentry>,
event: String,
}

impl Task for EvaluateThroughL2Task {
type Output = a3s_sentry::ThroughL2Result;
type JsValue = ThroughL2Result;

fn compute(&mut self) -> napi::Result<Self::Output> {
self.inner
.evaluate_through_l2(&self.event)
.ok_or_else(|| napi::Error::from_reason("event is not parseable"))
}

fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result<Self::JsValue> {
Ok(to_through_l2_result(output))
}
}

#[napi]
Expand All @@ -60,7 +93,9 @@ impl Sentry {
#[napi(factory)]
pub fn create(config: String) -> napi::Result<Sentry> {
CoreSentry::create(&config)
.map(|inner| Sentry { inner })
.map(|inner| Sentry {
inner: Arc::new(inner),
})
.map_err(|e| napi::Error::from_reason(e.to_string()))
}

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

/// Judge through L2 on the napi worker pool, preserving escalation for an external L3 worker.
#[napi]
pub fn evaluate_through_l2(&self, event: String) -> AsyncTask<EvaluateThroughL2Task> {
AsyncTask::new(EvaluateThroughL2Task {
inner: Arc::clone(&self.inner),
event,
})
}

/// Judge and, on a block carrying a target, write it to the configured deny-file. `null` if the
/// event isn't parseable.
#[napi]
Expand All @@ -83,6 +127,27 @@ impl Sentry {
}
}

fn to_through_l2_result(result: a3s_sentry::ThroughL2Result) -> ThroughL2Result {
let stage_status = match result.stage_status {
CoreThroughL2StageStatus::Completed => "completed",
CoreThroughL2StageStatus::Escalated => "escalated",
};
ThroughL2Result {
l1_decision: to_decision(result.l1_decision),
l2_decision: result.l2_decision.map(to_decision),
effective_decision: to_decision(result.effective_decision),
stage_status: stage_status.to_string(),
escalation_cause: result.escalation_cause.map(|cause| {
match cause {
a3s_sentry::EscalationCause::L1 => "l1",
a3s_sentry::EscalationCause::L2 => "l2",
a3s_sentry::EscalationCause::Sae => "sae",
}
.to_string()
}),
}
}

fn to_decision(d: a3s_sentry::Decision) -> Decision {
let verdict = match d.verdict {
Verdict::Allow => "allow",
Expand Down
59 changes: 59 additions & 0 deletions sdk/typescript/test/sdk.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,65 @@ test("L2 tier: an escalating event reaches L2 (unreachable URL → escalate, tie
assert.equal(d.tier, "Llm");
});

test("evaluateThroughL2 preserves escalation without invoking L3", async () => {
const dir = mkdtempSync(join(tmpdir(), "sentry-through-l2-"));
try {
const marker = join(dir, "l3-called");
const bin = join(dir, "mock-agent.sh");
writeFileSync(
bin,
`#!/bin/sh\ntouch "${marker}"\necho '{"verdict":"block","severity":"critical","reason":"unexpected L3"}'\n`,
);
chmodSync(bin, 0o755);
const s = Sentry.create(`
fail_closed = true
speculate = "low"
llm { url = "http://127.0.0.1:1/v1" }
agent { bin = "${bin}" }
`);

const result = await s.evaluateThroughL2(
fileAccess(1, "/home/u/.aws/credentials", false),
);
assert.equal(result.l1Decision.verdict, "escalate");
assert.equal(result.l2Decision.verdict, "escalate");
assert.equal(result.effectiveDecision.verdict, "escalate");
assert.equal(result.effectiveDecision.tier, "Llm");
assert.equal(result.stageStatus, "escalated");
assert.equal(result.escalationCause, "l2");
assert.throws(() => readFileSync(marker), /ENOENT/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("incomplete ToolExec evidence stops at L1 instead of becoming allow", async () => {
const s = Sentry.create(`
fail_closed = false
llm { url = "http://127.0.0.1:1/v1" }
`);
const event = JSON.stringify({
event: {
ToolExec: {
pid: 1,
argv: ["echo", "safe-prefix"],
argv_truncated: true,
},
},
});

const direct = s.evaluate(event);
assert.equal(direct.verdict, "escalate");
assert.equal(direct.tier, "Rules");
assert.match(direct.reason, /incomplete ToolExec evidence/);

const staged = await s.evaluateThroughL2(event);
assert.equal(staged.effectiveDecision.verdict, "escalate");
assert.equal(staged.effectiveDecision.tier, "Rules");
assert.equal(staged.l2Decision, undefined);
assert.equal(staged.escalationCause, "l1");
});

test("create() throws on a bad ACL config", () => {
assert.throws(() => Sentry.create("this is not valid acl {{{"), /parsing sentry ACL config/);
});
Expand Down
20 changes: 20 additions & 0 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ pub enum Event {
pid: u32,
#[serde(default)]
argv: Vec<String>,
#[serde(default)]
argv_truncated: bool,
#[serde(default)]
argv_incomplete: bool,
},
SslContent {
pid: u32,
Expand Down Expand Up @@ -105,6 +109,22 @@ pub enum Event {
}

impl Event {
/// Whether the observer could not provide the complete command evidence. A downstream model
/// cannot safely infer that an omitted suffix was benign, so this condition must never become
/// an ordinary allow decision.
pub fn evidence_incomplete(&self) -> bool {
matches!(
self,
Event::ToolExec {
argv_truncated: true,
..
} | Event::ToolExec {
argv_incomplete: true,
..
}
)
}

/// The variant name — the L1 rule `on` selector (`"ToolExec"`, `"SslContent"`, …).
pub fn name(&self) -> &'static str {
match self {
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub use event::{Event, Identity, ObservedEvent};
pub use inline::{Direction, InlineDecision, Redaction};
pub use llm::LlmJudge;
pub use metrics::Metrics;
pub use pipeline::{Judge, Pipeline};
pub use pipeline::{EscalationCause, Judge, Pipeline, ThroughL2Result, ThroughL2StageStatus};
pub use rules::{default_rules, LiveRules, RuleEngine, RuleSpec};
pub use sae::{FeatureDict, FeatureLabel, SaeJudge};
pub use sdk::Sentry;
Expand Down
Loading
Loading